it just works
This commit is contained in:
@@ -11,4 +11,5 @@ src/server/core/yt-dlp
|
||||
.env
|
||||
*.mp4
|
||||
*.ytdl
|
||||
*.db
|
||||
*.db
|
||||
build/
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,4 +12,6 @@ src/server/core/yt-dlp
|
||||
*.part
|
||||
*.db
|
||||
downloads
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
build/
|
||||
yt-dlp-webui
|
||||
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
default:
|
||||
go build -o yt-dlp-webui main.go
|
||||
|
||||
all:
|
||||
cd frontend && pnpm build && cd ..
|
||||
go build -o yt-dlp-webui main.go
|
||||
|
||||
multiarch:
|
||||
GOOS=linux GOARCH=arm go build -o yt-dlp-webui_linux-arm *.go
|
||||
GOOS=linux GOARCH=arm64 go build -o yt-dlp-webui_linux-arm64 *.go
|
||||
GOOS=linux GOARCH=amd64 go build -o yt-dlp-webui_linux-amd64 *.go
|
||||
mkdir -p build
|
||||
mv yt-dlp-webui* build
|
||||
|
||||
clean:
|
||||
rm -rf build
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -3,17 +3,8 @@
|
||||
"version": "1.1.0",
|
||||
"description": "A terrible webUI for yt-dlp, all-in-one solution.",
|
||||
"scripts": {
|
||||
"dev": "nodemon dist/main.js",
|
||||
"start": "node dist/main.js",
|
||||
"watch": "tsc --build -w",
|
||||
"build": "vite build",
|
||||
"build-server": "tsc --build",
|
||||
"build-all": "tsc --build && npm run build && npm run fetch",
|
||||
"clean": "tsc --build --clean",
|
||||
"clean-all": "rm -r dist",
|
||||
"fe": "vite",
|
||||
"fetch-dev": "./fetch-yt-dlp.sh && mv yt-dlp ./server/core",
|
||||
"fetch": "./fetch-yt-dlp.sh && mv yt-dlp ./dist/core"
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"author": "marcobaobao",
|
||||
"license": "ISC",
|
||||
@@ -24,26 +15,17 @@
|
||||
"@mui/icons-material": "^5.6.2",
|
||||
"@mui/material": "^5.6.4",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"koa": "^2.13.4",
|
||||
"koa-router": "^10.1.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"radash": "^10.6.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.0.1",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"rxjs": "^7.4.0",
|
||||
"socket.io": "^4.3.2",
|
||||
"socket.io-client": "^4.3.2",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.0.2",
|
||||
"@types/koa": "^2.13.4",
|
||||
"@types/koa-router": "^7.4.4",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "^17.0.31",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -17,13 +17,12 @@ import {
|
||||
} from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Provider, useSelector } from "react-redux";
|
||||
import {
|
||||
BrowserRouter as Router, Link, Route,
|
||||
Routes
|
||||
} from 'react-router-dom';
|
||||
import ArchivedDownloads from "./Archived";
|
||||
import { AppBar } from "./components/AppBar";
|
||||
import { Drawer } from "./components/Drawer";
|
||||
import Home from "./Home";
|
||||
@@ -163,9 +162,8 @@ function AppContent() {
|
||||
>
|
||||
<Toolbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home socket={socket}></Home>}></Route>
|
||||
<Route path="/settings" element={<Settings socket={socket}></Settings>}></Route>
|
||||
<Route path="/downloaded" element={<ArchivedDownloads></ArchivedDownloads>}></Route>
|
||||
<Route path="/" element={<Home socket={socket} />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Backdrop, CircularProgress, Container, Grid } from "@mui/material";
|
||||
import { ArchiveResult } from "./components/ArchiveResult";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "./stores/store";
|
||||
|
||||
export default function archivedDownloads() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [archived, setArchived] = useState([]);
|
||||
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`http://${settings.serverAddr}:3022/getAllDownloaded`)
|
||||
.then(res => res.json())
|
||||
.then(data => setArchived(data))
|
||||
.then(() => setLoading(false))
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={loading}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Backdrop>
|
||||
{/*
|
||||
archived.length > 0 ?
|
||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||
{
|
||||
archived.map((el, idx) =>
|
||||
<Grid key={`${idx}-${el.id}`} item xs={4} sm={4} md={4}>
|
||||
<ArchiveResult
|
||||
url={`http://${settings.serverAddr}:3022/stream/${el.id}`}
|
||||
thumbnail={el.img} title={el.title}
|
||||
/>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</Grid>
|
||||
: null
|
||||
*/}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -21,16 +21,14 @@ import {
|
||||
import { Buffer } from 'buffer';
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { CliArguments } from "./classes";
|
||||
import { StackableResult } from "./components/StackableResult";
|
||||
import { serverStates } from "./events";
|
||||
import { CliArguments } from "./features/core/argsParser";
|
||||
import I18nBuilder from "./features/core/intl";
|
||||
import { RPCClient } from "./features/core/rpcClient";
|
||||
import { connected, setFreeSpace } from "./features/status/statusSlice";
|
||||
import { I18nBuilder } from "./i18n";
|
||||
import { IDLMetadata, IMessage } from "./interfaces";
|
||||
import { RPCClient } from "./rpcClient";
|
||||
import { RootState } from "./stores/store";
|
||||
import { RPCResult } from "./types";
|
||||
import { isValidURL, toFormatArgs, updateInStateMap } from "./utils";
|
||||
import { IDLMetadata, RPCResult } from "./types";
|
||||
import { isValidURL, toFormatArgs } from "./utils";
|
||||
|
||||
type Props = {
|
||||
socket: WebSocket
|
||||
@@ -43,11 +41,7 @@ export default function Home({ socket }: Props) {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// ephemeral state
|
||||
const [progressMap, setProgressMap] = useState(new Map<number, number>());
|
||||
const [messageMap, setMessageMap] = useState(new Map<number, IMessage>());
|
||||
|
||||
const [activeDownloads, setActiveDownloads] = useState(new Array<RPCResult>());
|
||||
const [downloadInfoMap, setDownloadInfoMap] = useState(new Map<number, IDLMetadata>());
|
||||
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
|
||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
|
||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
|
||||
@@ -60,6 +54,7 @@ export default function Home({ socket }: Props) {
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [workingUrl, setWorkingUrl] = useState('');
|
||||
|
||||
const [showBackdrop, setShowBackdrop] = useState(false);
|
||||
const [showToast, setShowToast] = useState(true);
|
||||
|
||||
@@ -89,9 +84,9 @@ export default function Home({ socket }: Props) {
|
||||
useEffect(() => {
|
||||
socket.onmessage = (event) => {
|
||||
const res = client.decode(event.data)
|
||||
if (showBackdrop) {
|
||||
setShowBackdrop(false)
|
||||
}
|
||||
|
||||
setShowBackdrop(false)
|
||||
|
||||
switch (typeof res.result) {
|
||||
case 'object':
|
||||
setActiveDownloads(
|
||||
@@ -127,6 +122,8 @@ export default function Home({ socket }: Props) {
|
||||
client.download(
|
||||
immediate || url || workingUrl,
|
||||
cliArgs.toString() + toFormatArgs(codes),
|
||||
availableDownloadPaths[downloadPath] ?? '',
|
||||
fileNameOverride
|
||||
)
|
||||
|
||||
setUrl('')
|
||||
@@ -154,7 +151,6 @@ export default function Home({ socket }: Props) {
|
||||
|
||||
client.formats(url)
|
||||
?.then(formats => {
|
||||
console.log(formats)
|
||||
setDownloadFormats(formats.result)
|
||||
setShowBackdrop(false)
|
||||
resetInput()
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { debounceTime, distinctUntilChanged, map, of, takeWhile } from "rxjs";
|
||||
import { Socket } from "socket.io-client";
|
||||
import { CliArguments } from "./classes";
|
||||
import { CliArguments } from "./features/core/argsParser";
|
||||
import I18nBuilder from "./features/core/intl";
|
||||
import {
|
||||
LanguageUnion,
|
||||
setCliArgs,
|
||||
@@ -34,16 +34,11 @@ import {
|
||||
setTheme,
|
||||
ThemeUnion
|
||||
} from "./features/settings/settingsSlice";
|
||||
import { alreadyUpdated, updated } from "./features/status/statusSlice";
|
||||
import { I18nBuilder } from "./i18n";
|
||||
import { updated } from "./features/status/statusSlice";
|
||||
import { RootState } from "./stores/store";
|
||||
import { validateDomain, validateIP } from "./utils";
|
||||
|
||||
type Props = {
|
||||
socket: WebSocket
|
||||
}
|
||||
|
||||
export default function Settings({ socket }: Props) {
|
||||
export default function Settings() {
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { EightK, FourK, Hd, Sd } from "@mui/icons-material";
|
||||
import { Button, Card, CardActionArea, CardActions, CardContent, CardMedia, Chip, LinearProgress, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import { IMessage } from "../interfaces";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ellipsis } from "../utils";
|
||||
|
||||
type Props = {
|
||||
@@ -22,6 +34,14 @@ export function StackableResult({
|
||||
size,
|
||||
stopCallback
|
||||
}: Props) {
|
||||
const [isCompleted, setIsCompleted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (percentage === '-1') {
|
||||
setIsCompleted(true)
|
||||
}
|
||||
}, [percentage])
|
||||
|
||||
const guessResolution = (xByY: string): any => {
|
||||
if (!xByY) return null;
|
||||
if (xByY.includes('4320')) return (<EightK color="primary" />);
|
||||
@@ -31,9 +51,10 @@ export function StackableResult({
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentageToNumber = () => Number(percentage.replace('%', ''))
|
||||
const percentageToNumber = () => isCompleted ? 100 : Number(percentage.replace('%', ''))
|
||||
|
||||
const roundMB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)}MiB`
|
||||
const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
|
||||
const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -54,21 +75,33 @@ export function StackableResult({
|
||||
<Skeleton />
|
||||
}
|
||||
<Stack direction="row" spacing={1} py={2}>
|
||||
<Chip label={'Downloading'} color="primary" />
|
||||
<Typography>{percentage}</Typography>
|
||||
<Typography>{speed}</Typography>
|
||||
<Typography>{roundMB(size ?? 0)}</Typography>
|
||||
<Chip
|
||||
label={isCompleted ? 'Completed' : 'Downloading'}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Typography>{!isCompleted ? percentage : ''}</Typography>
|
||||
<Typography> {!isCompleted ? formatSpeedMiB(speed) : ''}</Typography>
|
||||
<Typography>{roundMiB(size ?? 0)}</Typography>
|
||||
{guessResolution(resolution)}
|
||||
</Stack>
|
||||
{percentage ?
|
||||
<LinearProgress variant="determinate" value={percentageToNumber()} /> :
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={percentageToNumber()}
|
||||
color={isCompleted ? "secondary" : "primary"}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
<CardActions>
|
||||
<Button variant="contained" size="small" color="primary" onClick={stopCallback}>
|
||||
Stop
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={stopCallback}>
|
||||
{isCompleted ? "Clear" : "Stop"}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { on } from "../events";
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
export function Statistics() {
|
||||
const dataset = new Array<number>();
|
||||
const chartRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
on('dlSpeed', (data: CustomEvent<any>) => {
|
||||
dataset.push(data.detail)
|
||||
chartRef.current.update()
|
||||
})
|
||||
}, [])
|
||||
|
||||
const data = {
|
||||
labels: dataset.map(() => ''),
|
||||
datasets: [
|
||||
{
|
||||
data: dataset,
|
||||
label: 'download speed',
|
||||
borderColor: 'rgb(53, 162, 235)',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chart">
|
||||
<Line data={data} ref={chartRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import i18n from "./assets/i18n.yaml";
|
||||
import i18n from "../../assets/i18n.yaml";
|
||||
|
||||
export class I18nBuilder {
|
||||
export default class I18nBuilder {
|
||||
private language: string;
|
||||
private textMap = i18n.languages;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { RPCRequest, RPCResponse } from "./types"
|
||||
import type { IDLMetadata } from './interfaces'
|
||||
import type { RPCRequest, RPCResponse, IDLMetadata } from "../../types"
|
||||
|
||||
import { getHttpRPCEndpoint } from './utils'
|
||||
import { getHttpRPCEndpoint } from '../../utils'
|
||||
|
||||
export class RPCClient {
|
||||
private socket: WebSocket
|
||||
@@ -31,7 +30,7 @@ export class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
public download(url: string, args: string) {
|
||||
public download(url: string, args: string, pathOverride = '', renameTo = '') {
|
||||
if (url) {
|
||||
this.send({
|
||||
id: this.incrementSeq(),
|
||||
@@ -39,6 +38,8 @@ export class RPCClient {
|
||||
params: [{
|
||||
URL: url.split("?list").at(0)!,
|
||||
Params: args.split(" ").map(a => a.trim()),
|
||||
Path: pathOverride,
|
||||
Rename: renameTo,
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './src/App'
|
||||
import { App } from './App'
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root')!)
|
||||
root.render(
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface IMessage {
|
||||
status: string,
|
||||
progress?: string,
|
||||
size?: number,
|
||||
dlSpeed?: string
|
||||
pid: number
|
||||
}
|
||||
|
||||
export interface IDLMetadata {
|
||||
formats: Array<IDLFormat>,
|
||||
best: IDLFormat,
|
||||
thumbnail: string,
|
||||
title: string,
|
||||
}
|
||||
|
||||
export interface IDLFormat {
|
||||
format_id: string,
|
||||
format_note: string,
|
||||
fps: number,
|
||||
resolution: string,
|
||||
vcodec: string,
|
||||
acodec: string,
|
||||
}
|
||||
|
||||
export interface IDLMetadataAndPID {
|
||||
pid: number,
|
||||
metadata: IDLMetadata
|
||||
}
|
||||
|
||||
export interface IDLSpeed {
|
||||
effective: number,
|
||||
unit: string,
|
||||
}
|
||||
17
frontend/src/types.d.ts
vendored
17
frontend/src/types.d.ts
vendored
@@ -6,6 +6,7 @@ export type RPCMethods =
|
||||
| "Service.FreeSpace"
|
||||
| "Service.Formats"
|
||||
| "Service.DirectoryTree"
|
||||
| "Service.UpdateExecutable"
|
||||
|
||||
export type RPCRequest = {
|
||||
method: RPCMethods,
|
||||
@@ -41,4 +42,20 @@ export type RPCResult = {
|
||||
export type RPCParams = {
|
||||
URL: string
|
||||
Params?: string
|
||||
}
|
||||
|
||||
export interface IDLMetadata {
|
||||
formats: Array<IDLFormat>,
|
||||
best: IDLFormat,
|
||||
thumbnail: string,
|
||||
title: string,
|
||||
}
|
||||
|
||||
export interface IDLFormat {
|
||||
format_id: string,
|
||||
format_note: string,
|
||||
fps: number,
|
||||
resolution: string,
|
||||
vcodec: string,
|
||||
acodec: string,
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { IMessage } from "./interfaces"
|
||||
|
||||
/**
|
||||
* Validate an ip v4 via regex
|
||||
* @param {string} ipAddr
|
||||
@@ -65,36 +63,6 @@ export function detectSpeed(str: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a map stored in React State, in this specific impl. all maps have integer keys
|
||||
* @param k Map key
|
||||
* @param v Map value
|
||||
* @param target The target map saved in-state
|
||||
* @param callback calls React's StateAction function with the newly created Map
|
||||
* @param remove -optional- is it an update or a deletion operation?
|
||||
*/
|
||||
export function updateInStateMap<K, V>(k: K, v: any, target: Map<K, V>, callback: Function, remove: boolean = false) {
|
||||
if (remove) {
|
||||
const _target = target
|
||||
_target.delete(k)
|
||||
callback(new Map(_target))
|
||||
return;
|
||||
}
|
||||
callback(new Map(target.set(k, v)));
|
||||
}
|
||||
|
||||
export function updateInStateArray<T>(v: T, target: Array<T>, callback: Function) { }
|
||||
|
||||
/**
|
||||
* Pre like function
|
||||
* @param data
|
||||
* @returns formatted server message
|
||||
*/
|
||||
export function buildMessage(data: IMessage) {
|
||||
return `operation: ${data.status || '...'} \nprogress: ${data.progress || '?'} \nsize: ${data.size || '?'} \nspeed: ${data.dlSpeed || '?'}`;
|
||||
}
|
||||
|
||||
|
||||
export function toFormatArgs(codes: string[]): string {
|
||||
if (codes.length > 1) {
|
||||
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
||||
|
||||
@@ -9,10 +9,10 @@ export default defineConfig(() => {
|
||||
react(),
|
||||
ViteYaml(),
|
||||
],
|
||||
root: resolve(__dirname, 'frontend'),
|
||||
root: resolve(__dirname, '.'),
|
||||
build: {
|
||||
emptyOutDir: true,
|
||||
outDir: resolve(__dirname, 'dist', 'frontend'),
|
||||
outDir: resolve(__dirname, 'dist'),
|
||||
}
|
||||
}
|
||||
})
|
||||
1
go.mod
1
go.mod
@@ -23,4 +23,5 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.43.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
3
go.sum
3
go.sum
@@ -56,3 +56,6 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
31
main.go
31
main.go
@@ -3,34 +3,51 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
)
|
||||
|
||||
type ContextKey interface{}
|
||||
|
||||
var (
|
||||
port = os.Getenv("PORT")
|
||||
//go:embed dist/frontend
|
||||
port int
|
||||
downloadPath string
|
||||
downloaderPath string
|
||||
configFile string
|
||||
|
||||
//go:embed frontend/dist
|
||||
frontend embed.FS
|
||||
)
|
||||
|
||||
func init() {
|
||||
if port == "" {
|
||||
port = "3033"
|
||||
}
|
||||
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
|
||||
flag.StringVar(&downloadPath, "out", ".", "Directory where files will be saved")
|
||||
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
|
||||
flag.StringVar(&configFile, "conf", "", "yt-dlp-WebUI config file path")
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
func main() {
|
||||
frontend, err := fs.Sub(frontend, "dist/frontend")
|
||||
frontend, err := fs.Sub(frontend, "frontend/dist")
|
||||
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
cfg := config.Instance()
|
||||
|
||||
if configFile != "" {
|
||||
cfg.LoadFromFile(configFile)
|
||||
}
|
||||
|
||||
cfg.SetPort(port)
|
||||
cfg.DownloadPath(downloadPath)
|
||||
cfg.DownloaderPath(downloaderPath)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, ContextKey("port"), port)
|
||||
ctx = context.WithValue(ctx, ContextKey("frontend"), frontend)
|
||||
|
||||
2209
pnpm-lock.yaml
generated
2209
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,86 +0,0 @@
|
||||
import { createServer, Server } from 'http';
|
||||
import { parse as urlParse } from 'url';
|
||||
import { open, close, readFile, fstat } from 'fs';
|
||||
import { parse, join } from 'path';
|
||||
|
||||
namespace server {
|
||||
export const mimes = {
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/x-icon',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.css': 'text/css',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
}
|
||||
|
||||
class Jean {
|
||||
private workingDir: string;
|
||||
|
||||
/**
|
||||
* Jean static file server its only purpose is serving SPA and images
|
||||
* with the lowest impact possible.
|
||||
* @param workingDir sets the root directory automatically trying index.html
|
||||
* If specified the file in addition to the directory it will serve the
|
||||
* file directly.
|
||||
* *e.g* new Jean(path.join(__dirname, 'dist')) will try
|
||||
* index.html from the dist directory;
|
||||
* @author me :D
|
||||
*/
|
||||
|
||||
constructor(workingDir: string) {
|
||||
this.workingDir = workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a static file server
|
||||
* @returns an instance of a standard NodeJS http.Server
|
||||
*/
|
||||
public createServer(): Server {
|
||||
return createServer((req, res) => {
|
||||
// parse the current given url
|
||||
const parsedUrl = urlParse(req.url, false)
|
||||
// extract the pathname and guard it with the working dir
|
||||
let pathname = join(this.workingDir, `.${parsedUrl.pathname}`);
|
||||
// extract the file extension
|
||||
const ext = parse(pathname).ext;
|
||||
|
||||
// open the file or directory and fetch its descriptor
|
||||
open(pathname, 'r', (err, fd) => {
|
||||
// whoops, not found, send a 404
|
||||
if (err) {
|
||||
res.statusCode = 404;
|
||||
res.end(`File ${pathname} not found!`);
|
||||
return;
|
||||
}
|
||||
// something's gone wrong it's not a file or a directory
|
||||
fstat(fd, (err, stat) => {
|
||||
if (err) {
|
||||
res.statusCode = 500;
|
||||
res.end(err);
|
||||
}
|
||||
// try file index.html
|
||||
if (stat.isDirectory()) {
|
||||
pathname = join(pathname, 'index.html')
|
||||
}
|
||||
// read the file
|
||||
readFile(pathname, (err, data) => {
|
||||
if (err) {
|
||||
res.statusCode = 500;
|
||||
res.end(`Error reading the file: ${err}`);
|
||||
} else {
|
||||
// infer it's extension otherwise it's the index.html
|
||||
res.setHeader('Content-type', server.mimes[ext] || 'text/html');
|
||||
res.end(data);
|
||||
close(fd);
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default Jean;
|
||||
@@ -1,166 +0,0 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { ISettings } from '../interfaces/ISettings';
|
||||
import { availableParams } from '../utils/params';
|
||||
import Logger from '../utils/BetterLogger';
|
||||
import { IDownloadFormat, IDownloadMetadata } from '../interfaces/IDownloadMetadata';
|
||||
|
||||
const log = Logger.instance;
|
||||
|
||||
/**
|
||||
* Represents a download process that spawns yt-dlp.
|
||||
* @param url - The downlaod url.
|
||||
* @param params - The cli arguments passed by the frontend.
|
||||
* @param settings - The download settings passed by the frontend.
|
||||
*/
|
||||
|
||||
class Process {
|
||||
public readonly url: string;
|
||||
public readonly params: Array<string>;
|
||||
private settings: ISettings;
|
||||
private stdout: Readable;
|
||||
private pid: number;
|
||||
private metadata?: IDownloadMetadata;
|
||||
private exePath = join(__dirname, 'yt-dlp');
|
||||
private customFileName?: string;
|
||||
|
||||
private readonly template = `download:
|
||||
{
|
||||
"eta":%(progress.eta)s,
|
||||
"percentage":"%(progress._percent_str)s",
|
||||
"speed":"%(progress._speed_str)s",
|
||||
"size":%(info.filesize_approx)s
|
||||
}`
|
||||
.replace(/\s\s+/g, ' ')
|
||||
.replace('\n', '');
|
||||
|
||||
constructor(url: string, params: Array<string>, settings: any, customFileName?: string) {
|
||||
this.url = url;
|
||||
this.params = params || [];
|
||||
this.settings = settings
|
||||
this.stdout = undefined;
|
||||
this.pid = undefined;
|
||||
this.metadata = undefined;
|
||||
this.customFileName = customFileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* function that launch the download process, sets the stdout property and the pid
|
||||
* @param callback not yet implemented
|
||||
* @returns the process instance
|
||||
*/
|
||||
public async start(callback?: Function): Promise<this> {
|
||||
const sanitizedParams = this.params.filter((param: string) => availableParams.includes(param));
|
||||
|
||||
if (this.settings?.download_path) {
|
||||
if (this.settings.download_path.charAt(this.settings.download_path.length - 1) !== '/') {
|
||||
this.settings.download_path = `${this.settings.download_path}/`
|
||||
}
|
||||
}
|
||||
|
||||
const ytldp = spawn(this.exePath,
|
||||
[
|
||||
'-o', `${this.settings?.download_path || 'downloads/'}${this.customFileName || '%(title)s'}.%(ext)s`,
|
||||
'--progress-template', this.template,
|
||||
'--no-colors',
|
||||
]
|
||||
.concat(sanitizedParams)
|
||||
.concat((this.settings?.cliArgs ?? []).map(arg => arg.split(' ')).flat())
|
||||
.concat([this.url])
|
||||
);
|
||||
|
||||
this.pid = ytldp.pid;
|
||||
this.stdout = ytldp.stdout;
|
||||
|
||||
log.info('proc', `Spawned a new process, pid: ${this.pid}`)
|
||||
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* function used internally by the download process to fetch information, usually thumbnail and title
|
||||
* @returns Promise to the lock
|
||||
*/
|
||||
public getMetadata(): Promise<IDownloadMetadata> {
|
||||
if (!this.metadata) {
|
||||
let stdoutChunks = [];
|
||||
const ytdlpInfo = spawn(this.exePath, ['-J', this.url]);
|
||||
|
||||
ytdlpInfo.stdout.on('data', (data) => {
|
||||
stdoutChunks.push(data);
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ytdlpInfo.on('exit', () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(stdoutChunks);
|
||||
const json = JSON.parse(buffer.toString());
|
||||
const info = {
|
||||
formats: json.formats.map((format: IDownloadFormat) => {
|
||||
return {
|
||||
format_id: format.format_id ?? '',
|
||||
format_note: format.format_note ?? '',
|
||||
fps: format.fps ?? '',
|
||||
resolution: format.resolution ?? '',
|
||||
vcodec: format.vcodec ?? '',
|
||||
acodec: format.acodec ?? '',
|
||||
}
|
||||
}).filter((format: IDownloadFormat) => format.format_note !== 'storyboard'),
|
||||
best: {
|
||||
format_id: json.format_id ?? '',
|
||||
format_note: json.format_note ?? '',
|
||||
fps: json.fps ?? '',
|
||||
resolution: json.resolution ?? '',
|
||||
vcodec: json.vcodec ?? '',
|
||||
acodec: json.acodec ?? '',
|
||||
},
|
||||
thumbnail: json.thumbnail,
|
||||
title: json.title,
|
||||
}
|
||||
resolve(info);
|
||||
this.metadata = info;
|
||||
|
||||
} catch (e) {
|
||||
reject('failed fetching formats, downloading best available');
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
return new Promise((resolve) => { resolve(this.metadata!) });
|
||||
}
|
||||
|
||||
/**
|
||||
* function that kills the current process
|
||||
*/
|
||||
async kill() {
|
||||
spawn('kill', [String(this.pid)]).on('exit', () => {
|
||||
log.info('proc', `Stopped ${this.pid} because SIGKILL`)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* pid getter function
|
||||
* @returns {number} pid
|
||||
*/
|
||||
getPid(): number {
|
||||
if (!this.pid) {
|
||||
throw "Process isn't started"
|
||||
}
|
||||
return this.pid;
|
||||
}
|
||||
|
||||
/**
|
||||
* stdout getter function
|
||||
* @returns {Readable} stdout as stream
|
||||
*/
|
||||
getStdout(): Readable {
|
||||
return this.stdout
|
||||
}
|
||||
}
|
||||
|
||||
export default Process;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { resolve as pathResolve } from "path";
|
||||
import { readdir } from "fs";
|
||||
import { ISettings } from "../interfaces/ISettings";
|
||||
import Logger from "../utils/BetterLogger";
|
||||
|
||||
let settings: ISettings;
|
||||
const log = Logger.instance;
|
||||
|
||||
try {
|
||||
settings = require('../../settings.json');
|
||||
} catch (e) {
|
||||
log.warn('dl', 'settings.json not found');
|
||||
}
|
||||
|
||||
export function listDownloaded(ctx: any) {
|
||||
return new Promise((resolve, reject) => {
|
||||
readdir(pathResolve(settings.download_path || 'download'), (err, files) => {
|
||||
if (err) {
|
||||
reject({ err: true })
|
||||
return
|
||||
}
|
||||
ctx.body = files.map(file => {
|
||||
resolve({
|
||||
filename: file,
|
||||
path: pathResolve(file),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { from, interval } from 'rxjs';
|
||||
import { map, throttle } from 'rxjs/operators';
|
||||
import { Socket } from 'socket.io';
|
||||
import MemoryDB from '../db/memoryDB';
|
||||
import { IPayload } from '../interfaces/IPayload';
|
||||
import { ISettings } from '../interfaces/ISettings';
|
||||
import { CLIProgress } from '../types';
|
||||
import Logger from '../utils/BetterLogger';
|
||||
import Process from './Process';
|
||||
import { states } from './states';
|
||||
|
||||
// settings read from settings.json
|
||||
let settings: ISettings;
|
||||
const log = Logger.instance;
|
||||
|
||||
const mem_db = new MemoryDB();
|
||||
|
||||
try {
|
||||
settings = require('../../settings.json');
|
||||
}
|
||||
catch (e) {
|
||||
new Promise(resolve => setTimeout(resolve, 500))
|
||||
.then(() => log.warn('dl', 'settings.json not found, ignore if using Docker'));
|
||||
}
|
||||
/**
|
||||
* Get download info such as thumbnail, title, resolution and list all formats
|
||||
* @param socket
|
||||
* @param url
|
||||
*/
|
||||
export async function getFormatsAndMetadata(socket: Socket, url: string) {
|
||||
let p = new Process(url, [], settings);
|
||||
try {
|
||||
const formats = await p.getMetadata();
|
||||
socket.emit('available-formats', formats)
|
||||
} catch (e) {
|
||||
log.warn('dl', e)
|
||||
socket.emit('progress', {
|
||||
status: states.PROG_DONE,
|
||||
pid: -1,
|
||||
});
|
||||
} finally {
|
||||
p = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke a new download.
|
||||
* Called by the websocket messages listener.
|
||||
* @param {Socket} socket current connection socket
|
||||
* @param {object} payload frontend download payload
|
||||
* @returns
|
||||
*/
|
||||
export async function download(socket: Socket, payload: IPayload) {
|
||||
if (!payload || payload.url === '' || payload.url === null) {
|
||||
socket.emit('progress', { status: states.PROG_DONE });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = payload.url;
|
||||
const params = typeof payload.params !== 'object' ?
|
||||
payload.params.split(' ') :
|
||||
payload.params;
|
||||
|
||||
const renameTo = payload.renameTo
|
||||
|
||||
const scopedSettings: ISettings = {
|
||||
...settings,
|
||||
download_path: payload.path
|
||||
}
|
||||
|
||||
let p = new Process(url, params, scopedSettings, renameTo);
|
||||
|
||||
p.start().then(downloader => {
|
||||
mem_db.add(downloader)
|
||||
displayDownloadMetadata(downloader, socket);
|
||||
streamProcess(downloader, socket);
|
||||
});
|
||||
|
||||
// GC
|
||||
p = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send via websocket download info "chunk"
|
||||
* @param process
|
||||
* @param socket
|
||||
*/
|
||||
function displayDownloadMetadata(process: Process, socket: Socket) {
|
||||
process.getMetadata()
|
||||
.then(metadata => {
|
||||
socket.emit('metadata', {
|
||||
pid: process.getPid(),
|
||||
metadata: metadata,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
socket.emit('progress', {
|
||||
status: states.PROG_DONE,
|
||||
pid: process.getPid(),
|
||||
});
|
||||
log.warn('dl', e)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream via websocket download stdoud "chunks"
|
||||
* @param process
|
||||
* @param socket
|
||||
*/
|
||||
function streamProcess(process: Process, socket: Socket) {
|
||||
const emitAbort = () => {
|
||||
socket.emit('progress', {
|
||||
status: states.PROG_DONE,
|
||||
pid: process.getPid(),
|
||||
});
|
||||
}
|
||||
|
||||
from(process.getStdout().removeAllListeners()) // stdout as observable
|
||||
.pipe(
|
||||
throttle(() => interval(500)), // discard events closer than 500ms
|
||||
map(stdout => formatter(String(stdout), process.getPid()))
|
||||
)
|
||||
.subscribe({
|
||||
next: (stdout) => {
|
||||
socket.emit('progress', stdout)
|
||||
},
|
||||
complete: () => {
|
||||
process.kill().then(() => {
|
||||
emitAbort();
|
||||
mem_db.remove(process);
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
emitAbort();
|
||||
mem_db.remove(process);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all downloads.
|
||||
* If the server has just been launched retrieve the ones saved to the database.
|
||||
* If the server is running fetches them from the process pool.
|
||||
* @param {Socket} socket current connection socket
|
||||
* @returns
|
||||
*/
|
||||
export async function retrieveDownload(socket: Socket) {
|
||||
// it's a cold restart: the server has just been started with pending
|
||||
// downloads, so fetch them from the database and resume.
|
||||
|
||||
// if (coldRestart) {
|
||||
// coldRestart = false;
|
||||
// let downloads = [];
|
||||
// // sanitize
|
||||
// downloads = [...new Set(downloads.filter(el => el !== undefined))];
|
||||
// log.info('dl', `Cold restart, retrieving ${downloads.length} jobs`)
|
||||
// for (const entry of downloads) {
|
||||
// if (entry) {
|
||||
// await download(socket, entry);
|
||||
// }
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
|
||||
// it's an hot-reload the server it's running and the frontend ask for
|
||||
// the pending job: retrieve them from the "in-memory database" (ProcessPool)
|
||||
|
||||
const _poolSize = mem_db.size()
|
||||
log.info('dl', `Retrieving ${_poolSize} jobs from pool`)
|
||||
socket.emit('pending-jobs', _poolSize)
|
||||
|
||||
const it = mem_db.iterator();
|
||||
|
||||
// resume the jobs
|
||||
for (const entry of it) {
|
||||
const [, process] = entry
|
||||
displayDownloadMetadata(process, socket);
|
||||
streamProcess(process, socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a specific download if pid is provided, in the other case
|
||||
* calls the abortAllDownloads function
|
||||
* @see abortAllDownloads
|
||||
* @param {Socket} socket currenct connection socket
|
||||
* @param {*} args args sent by the frontend. MUST contain the PID.
|
||||
* @returns
|
||||
*/
|
||||
export function abortDownload(socket: Socket, args: any) {
|
||||
if (!args) {
|
||||
abortAllDownloads(socket);
|
||||
return;
|
||||
}
|
||||
const { pid } = args;
|
||||
|
||||
spawn('kill', [pid])
|
||||
.on('exit', () => {
|
||||
socket.emit('progress', {
|
||||
status: states.PROC_ABORT,
|
||||
process: pid,
|
||||
});
|
||||
log.warn('dl', `Aborting download ${pid}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unconditionally kills all yt-dlp process.
|
||||
* @param {Socket} socket currenct connection socket
|
||||
*/
|
||||
export function abortAllDownloads(socket: Socket) {
|
||||
spawn('killall', ['yt-dlp'])
|
||||
.on('exit', () => {
|
||||
socket.emit('progress', { status: states.PROC_ABORT });
|
||||
log.info('dl', 'Aborting downloads');
|
||||
});
|
||||
mem_db.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pool current size
|
||||
*/
|
||||
export function getQueueSize(): number {
|
||||
return mem_db.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private Formats the yt-dlp stdout to a frontend-readable format
|
||||
* @param {string} stdout stdout as string
|
||||
* @param {number} pid current process id relative to stdout
|
||||
* @returns
|
||||
*/
|
||||
const formatter = (stdout: string, pid: number) => {
|
||||
try {
|
||||
const p: CLIProgress = JSON.parse(stdout);
|
||||
if (p) {
|
||||
return {
|
||||
status: states.PROC_DOWNLOAD,
|
||||
progress: p.percentage,
|
||||
size: p.size,
|
||||
dlSpeed: p.speed,
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
progress: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Possible server states map
|
||||
*/
|
||||
export const states = {
|
||||
PROC_DOWNLOAD: 'download',
|
||||
PROC_MERGING: 'merging',
|
||||
PROC_ABORT: 'abort',
|
||||
PROG_DONE: 'status_done',
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { stat, createReadStream } from 'fs';
|
||||
import { lookup } from 'mime-types';
|
||||
|
||||
export function streamer(ctx: any, next: any) {
|
||||
const filepath = ''
|
||||
stat(filepath, (err, stat) => {
|
||||
if (err) {
|
||||
ctx.response.status = 404;
|
||||
ctx.body = { err: 'resource not found' };
|
||||
next();
|
||||
}
|
||||
const fileSize = stat.size;
|
||||
const range = ctx.headers.range;
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = end - start + 1;
|
||||
const file = createReadStream(filepath, { start, end });
|
||||
const head = {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': lookup(filepath)
|
||||
};
|
||||
ctx.res.writeHead(206, head);
|
||||
file.pipe(ctx.res);
|
||||
next();
|
||||
} else {
|
||||
const head = {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': 'video/mp4'
|
||||
};
|
||||
ctx.res.writeHead(200, head);
|
||||
createReadStream(ctx.params.filepath).pipe(ctx.res);
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Represents a download process that spawns yt-dlp.
|
||||
*/
|
||||
|
||||
import Process from "../core/Process";
|
||||
|
||||
class MemoryDB {
|
||||
private _pool: Map<number, Process>
|
||||
private _size: number
|
||||
|
||||
constructor() {
|
||||
this.init()
|
||||
}
|
||||
|
||||
private init() {
|
||||
this._pool = new Map<number, Process>()
|
||||
this._size = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool size getter
|
||||
* @returns {number} pool's size
|
||||
*/
|
||||
size(): number {
|
||||
return this._size
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a process to the pool
|
||||
* @param {Process} process
|
||||
*/
|
||||
add(process: Process) {
|
||||
this._pool.set(process.getPid(), process)
|
||||
this._size++
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a process from the pool
|
||||
* @param {Process} process
|
||||
*/
|
||||
remove(process: Process) {
|
||||
if (this._size === 0) return
|
||||
this._pool.delete(process.getPid())
|
||||
this._size--
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a process from the pool by its pid
|
||||
* @param {number} pid
|
||||
*/
|
||||
removeByPid(pid: number) {
|
||||
this._pool.delete(pid)
|
||||
}
|
||||
|
||||
/**
|
||||
* get an iterator for the pool
|
||||
* @returns {IterableIterator} iterator
|
||||
*/
|
||||
iterator(): IterableIterator<[number, Process]> {
|
||||
return this._pool.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* get a process by its pid
|
||||
* @param {number} pid
|
||||
* @returns {Process}
|
||||
*/
|
||||
getByPid(pid: number): Process {
|
||||
return this._pool.get(pid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear memory db
|
||||
*/
|
||||
flush() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
|
||||
export default MemoryDB;
|
||||
@@ -1,15 +0,0 @@
|
||||
export interface IDownloadMetadata {
|
||||
formats: Array<IDownloadFormat>,
|
||||
best: IDownloadFormat,
|
||||
thumbnail: string,
|
||||
title: string,
|
||||
}
|
||||
|
||||
export interface IDownloadFormat {
|
||||
format_id: string,
|
||||
format_note: string,
|
||||
fps: number,
|
||||
resolution: string,
|
||||
vcodec: string,
|
||||
acodec: string,
|
||||
}
|
||||
13
server-node/src/interfaces/IPayload.d.ts
vendored
13
server-node/src/interfaces/IPayload.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Represent a download payload sent by the frontend
|
||||
*/
|
||||
|
||||
export interface IPayload {
|
||||
url: string
|
||||
params: Array<string> | string
|
||||
path: string
|
||||
title?: string
|
||||
thumbnail?: string
|
||||
size?: string
|
||||
renameTo?: string
|
||||
}
|
||||
14
server-node/src/interfaces/IRecord.d.ts
vendored
14
server-node/src/interfaces/IRecord.d.ts
vendored
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Represent a download db record
|
||||
*/
|
||||
|
||||
export interface IRecord {
|
||||
uid: string,
|
||||
url: string,
|
||||
title: string,
|
||||
thumbnail: string,
|
||||
created: Date,
|
||||
size: string,
|
||||
pid: number,
|
||||
params: string,
|
||||
}
|
||||
5
server-node/src/interfaces/ISettings.d.ts
vendored
5
server-node/src/interfaces/ISettings.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
export interface ISettings {
|
||||
download_path: string,
|
||||
cliArgs?: string[],
|
||||
port?: number,
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { splash } from './utils/logger';
|
||||
import { join } from 'path';
|
||||
import { Server } from 'socket.io';
|
||||
import { ytdlpUpdater } from './utils/updater';
|
||||
import {
|
||||
download,
|
||||
abortDownload,
|
||||
retrieveDownload,
|
||||
abortAllDownloads,
|
||||
getFormatsAndMetadata
|
||||
} from './core/downloader';
|
||||
import { getFreeDiskSpace } from './utils/procUtils';
|
||||
import { listDownloaded } from './core/downloadArchive';
|
||||
import { createServer } from 'http';
|
||||
import { streamer } from './core/streamer';
|
||||
import * as Koa from 'koa';
|
||||
import * as Router from 'koa-router';
|
||||
import * as serve from 'koa-static';
|
||||
import * as cors from '@koa/cors';
|
||||
import Logger from './utils/BetterLogger';
|
||||
import { ISettings } from './interfaces/ISettings';
|
||||
import { directoryTree } from './utils/directoryUtils';
|
||||
|
||||
const app = new Koa();
|
||||
const server = createServer(app.callback());
|
||||
const router = new Router();
|
||||
const log = Logger.instance;
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
});
|
||||
|
||||
let settings: ISettings;
|
||||
|
||||
try {
|
||||
settings = require('../settings.json');
|
||||
} catch (e) {
|
||||
log.warn('settings', 'file not found, ignore if using Docker');
|
||||
}
|
||||
|
||||
// Koa routing
|
||||
router.get('/settings', (ctx, next) => {
|
||||
ctx.redirect('/')
|
||||
next()
|
||||
})
|
||||
router.get('/downloaded', (ctx, next) => {
|
||||
ctx.redirect('/')
|
||||
next()
|
||||
})
|
||||
router.get('/archive', (ctx, next) => {
|
||||
listDownloaded(ctx)
|
||||
.then((res: any) => {
|
||||
ctx.body = res
|
||||
next()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
ctx.body = err;
|
||||
next()
|
||||
})
|
||||
})
|
||||
router.get('/stream/:filepath', (ctx, next) => {
|
||||
streamer(ctx, next)
|
||||
})
|
||||
router.get('/tree', (ctx, next) => {
|
||||
ctx.body = directoryTree()
|
||||
next()
|
||||
})
|
||||
|
||||
// WebSocket listeners
|
||||
io.on('connection', socket => {
|
||||
log.info('ws', `${socket.handshake.address} connected!`)
|
||||
|
||||
socket.on('send-url', (args) => {
|
||||
log.info('ws', args?.url)
|
||||
download(socket, args)
|
||||
})
|
||||
socket.on('send-url-format-selection', (args) => {
|
||||
log.info('ws', `Formats ${args?.url}`)
|
||||
if (args.url) getFormatsAndMetadata(socket, args?.url)
|
||||
})
|
||||
socket.on('abort', (args) => {
|
||||
abortDownload(socket, args)
|
||||
})
|
||||
socket.on('abort-all', () => {
|
||||
abortAllDownloads(socket)
|
||||
})
|
||||
socket.on('update-bin', () => {
|
||||
ytdlpUpdater(socket)
|
||||
})
|
||||
socket.on('retrieve-jobs', () => {
|
||||
retrieveDownload(socket)
|
||||
})
|
||||
socket.on('disk-space', () => {
|
||||
getFreeDiskSpace(socket, settings.download_path || 'downloads/')
|
||||
})
|
||||
})
|
||||
|
||||
io.on('disconnect', (socket) => {
|
||||
log.info('ws', `${socket.handshake.address} disconnected`)
|
||||
})
|
||||
|
||||
app.use(serve(join(__dirname, 'frontend')))
|
||||
app.use(cors())
|
||||
app.use(router.routes())
|
||||
|
||||
server.listen(process.env.PORT || settings.port || 3022)
|
||||
|
||||
splash()
|
||||
log.info('http', `Server started on port ${process.env.PORT || settings.port || 3022}`)
|
||||
|
||||
/**
|
||||
* Cleanup handler
|
||||
*/
|
||||
const gracefullyStop = () => {
|
||||
log.warn('proc', 'Shutting down...')
|
||||
io.disconnectSockets(true)
|
||||
server.close()
|
||||
log.info('proc', 'Done!')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Intercepts singnals and perform cleanups before shutting down.
|
||||
process
|
||||
.on('SIGTERM', () => gracefullyStop())
|
||||
.on('SIGUSR1', () => gracefullyStop())
|
||||
.on('SIGUSR2', () => gracefullyStop())
|
||||
6
server-node/src/types/index.d.ts
vendored
6
server-node/src/types/index.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
export type CLIProgress = {
|
||||
percentage: string
|
||||
speed: string
|
||||
size: number
|
||||
eta: number
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
const ansi = {
|
||||
reset: '\u001b[0m',
|
||||
red: '\u001b[31m',
|
||||
cyan: '\u001b[36m',
|
||||
green: '\u001b[32m',
|
||||
yellow: '\u001b[93m',
|
||||
bold: '\u001b[1m',
|
||||
normal: '\u001b[22m',
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private static _instance: Logger;
|
||||
|
||||
constructor() { };
|
||||
|
||||
static get instance() {
|
||||
if (this._instance) {
|
||||
return this._instance
|
||||
}
|
||||
this._instance = new Logger()
|
||||
return this._instance;
|
||||
}
|
||||
/**
|
||||
* Print a standard info message
|
||||
* @param {string} proto the context/protocol/section outputting the message
|
||||
* @param {string} args the acutal message
|
||||
*/
|
||||
public info(proto: string, args: string) {
|
||||
process.stdout.write(
|
||||
this.formatter(proto, args)
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Print a warn message
|
||||
* @param {string} proto the context/protocol/section outputting the message
|
||||
* @param {string} args the acutal message
|
||||
*/
|
||||
public warn(proto: string, args: string) {
|
||||
process.stdout.write(
|
||||
`${ansi.yellow}${this.formatter(proto, args)}${ansi.reset}`
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Print an error message
|
||||
* @param {string} proto the context/protocol/section outputting the message
|
||||
* @param {string} args the acutal message
|
||||
*/
|
||||
public err(proto: string, args: string) {
|
||||
process.stdout.write(
|
||||
`${ansi.red}${this.formatter(proto, args)}${ansi.reset}`
|
||||
)
|
||||
}
|
||||
|
||||
private formatter(proto: any, args: any) {
|
||||
return `${ansi.bold}[${proto}]${ansi.normal}\t${args}\n`
|
||||
}
|
||||
}
|
||||
|
||||
export default Logger;
|
||||
@@ -1,59 +0,0 @@
|
||||
import { readdirSync, statSync } from "fs";
|
||||
import { ISettings } from "../interfaces/ISettings";
|
||||
|
||||
let settings: ISettings;
|
||||
|
||||
class Node {
|
||||
public path: string
|
||||
public children: Node[]
|
||||
|
||||
constructor(path: string) {
|
||||
this.path = path
|
||||
this.children = []
|
||||
}
|
||||
}
|
||||
|
||||
function buildTreeDFS(rootPath: string, directoryOnly: boolean) {
|
||||
const root = new Node(rootPath)
|
||||
const stack: Node[] = []
|
||||
const flattened: string[] = []
|
||||
|
||||
stack.push(root)
|
||||
flattened.push(rootPath)
|
||||
|
||||
while (stack.length) {
|
||||
const current = stack.pop()
|
||||
if (current) {
|
||||
const children = readdirSync(current.path)
|
||||
for (const it of children) {
|
||||
const childPath = `${current.path}/${it}`
|
||||
const childNode = new Node(childPath)
|
||||
|
||||
if (directoryOnly) {
|
||||
if (statSync(childPath).isDirectory()) {
|
||||
current.children.push(childNode)
|
||||
stack.push(childNode)
|
||||
flattened.push(childNode.path)
|
||||
}
|
||||
} else {
|
||||
current.children.push(childNode)
|
||||
if (statSync(childPath).isDirectory()) {
|
||||
stack.push(childNode)
|
||||
flattened.push(childNode.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tree: root,
|
||||
flat: flattened
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
settings = require('../../settings.json');
|
||||
} catch (e) { }
|
||||
|
||||
export const directoryTree = () => buildTreeDFS(settings.download_path || 'downloads', true)
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Simplest logger function, takes two argument: first one put between
|
||||
* square brackets (the protocol), the second one it's the effective message
|
||||
* @param {string} proto protocol
|
||||
* @param {string} args message
|
||||
*/
|
||||
export const logger = (proto: string, args: string) => {
|
||||
console.log(`[${proto}]\t${args}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI splash
|
||||
*/
|
||||
|
||||
export const splash = () => {
|
||||
const fg = "\u001b[38;2;50;113;168m"
|
||||
const reset = "\u001b[0m"
|
||||
console.log(`${fg} __ ____ __ __ ______`)
|
||||
console.log(" __ __/ /________/ / /__ _ _____ / / / / / / _/")
|
||||
console.log(" / // / __/___/ _ / / _ \\ | |/|/ / -_) _ \\/ /_/ // / ")
|
||||
console.log(" \\_, /\\__/ \\_,_/_/ .__/ |__,__/\\__/_.__/\\____/___/ ")
|
||||
console.log(`/___/ /_/ \n${reset}`)
|
||||
console.log(" yt-dlp-webUI - A web-ui for yt-dlp, simply enough")
|
||||
console.log("---------------------------------------------------\n")
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const availableParams = [
|
||||
'--no-mtime',
|
||||
'-x',
|
||||
]
|
||||
@@ -1,36 +0,0 @@
|
||||
import { exec, spawn } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import Logger from './BetterLogger';
|
||||
|
||||
const log = Logger.instance;
|
||||
|
||||
/**
|
||||
* Browse /proc in order to find the specific pid
|
||||
* @param {number} pid
|
||||
* @returns {*} process stats if any
|
||||
*/
|
||||
export function existsInProc(pid: number): any {
|
||||
try {
|
||||
return statSync(`/proc/${pid}`)
|
||||
} catch (e) {
|
||||
log.warn('proc', `pid ${pid} not found in procfs`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills a process with a sys-call
|
||||
* @param {number} pid the killed process pid
|
||||
*/
|
||||
export async function killProcess(pid: number) {
|
||||
const res = spawn('kill', [String(pid)])
|
||||
res.on('exit', () => {
|
||||
log.info('proc', `Successfully killed yt-dlp process, pid: ${pid}`)
|
||||
})
|
||||
}
|
||||
|
||||
export function getFreeDiskSpace(socket: any, path: string) {
|
||||
const message: string = 'free-space';
|
||||
exec(`df -h ${path} | tail -1 | awk '{print $4}'`, (_, stdout) => {
|
||||
socket.emit(message, stdout)
|
||||
})
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { get } from 'https';
|
||||
import { rmSync, createWriteStream, chmod } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// endpoint to github API
|
||||
const options = {
|
||||
hostname: 'api.github.com',
|
||||
path: '/repos/yt-dlp/yt-dlp/releases/latest',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0'
|
||||
},
|
||||
method: 'GET',
|
||||
port: 443,
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the binary url based on the release tag
|
||||
* @param {string} release yt-dlp GitHub release tag
|
||||
* @returns {*} the fetch options with the correct tag and headers
|
||||
*/
|
||||
function buildDonwloadOptions(release) {
|
||||
return {
|
||||
hostname: 'github.com',
|
||||
path: `/yt-dlp/yt-dlp/releases/download/${release}/yt-dlp`,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0'
|
||||
},
|
||||
method: 'GET',
|
||||
port: 443,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* gets the yt-dlp latest binary URL from GitHub API
|
||||
*/
|
||||
async function update() {
|
||||
// ensure that the binary has been removed
|
||||
try {
|
||||
rmSync(join(__dirname, '..', 'core', 'yt-dlp'))
|
||||
}
|
||||
catch (e) {
|
||||
console.log('file not found!')
|
||||
}
|
||||
// body buffer
|
||||
let chunks = []
|
||||
get(options, res => {
|
||||
// push the http packets chunks into the buffer
|
||||
res.on('data', chunk => {
|
||||
chunks.push(chunk)
|
||||
});
|
||||
// the connection has ended so build the body from the buffer
|
||||
// parse it as a JSON and get the tag_name
|
||||
res.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks)
|
||||
const release = JSON.parse(buffer.toString())['tag_name']
|
||||
console.log('The latest release is:', release)
|
||||
// invoke the binary downloader
|
||||
downloadBinary(buildDonwloadOptions(release))
|
||||
})
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Utility that Pipes the latest binary to a file
|
||||
* @param {string} url yt-dlp GitHub release url
|
||||
*/
|
||||
function downloadBinary(url) {
|
||||
get(url, res => {
|
||||
// if it is a redirect follow the url
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
return downloadBinary(res.headers.location)
|
||||
}
|
||||
let bin = createWriteStream(join(__dirname, '..', 'core', 'yt-dlp'))
|
||||
res.pipe(bin)
|
||||
// once the connection has ended make the file executable
|
||||
res.on('end', () => {
|
||||
chmod(join(__dirname, '..', 'core', 'yt-dlp'), 0o775, err => {
|
||||
err ? console.error('failed updating!') : console.log('done!')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Invoke the yt-dlp update procedure
|
||||
* @param {Socket} socket the current connection socket
|
||||
*/
|
||||
export function ytdlpUpdater(socket) {
|
||||
update().then(() => {
|
||||
socket.emit('updated')
|
||||
})
|
||||
}
|
||||
60
server/config/config_singleton.go
Normal file
60
server/config/config_singleton.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var lock = &sync.Mutex{}
|
||||
|
||||
type serverConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
DownloadPath string `yaml:"downloadPath"`
|
||||
DownloaderPath string `yaml:"downloaderPath"`
|
||||
}
|
||||
|
||||
type config struct {
|
||||
cfg serverConfig
|
||||
}
|
||||
|
||||
func (c *config) LoadFromFile(filename string) (serverConfig, error) {
|
||||
bytes, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return serverConfig{}, err
|
||||
}
|
||||
|
||||
yaml.Unmarshal(bytes, &c.cfg)
|
||||
|
||||
return c.cfg, nil
|
||||
}
|
||||
|
||||
func (c *config) GetConfig() serverConfig {
|
||||
return c.cfg
|
||||
}
|
||||
|
||||
func (c *config) SetPort(port int) {
|
||||
c.cfg.Port = port
|
||||
}
|
||||
|
||||
func (c *config) DownloadPath(path string) {
|
||||
c.cfg.DownloadPath = path
|
||||
}
|
||||
|
||||
func (c *config) DownloaderPath(path string) {
|
||||
c.cfg.DownloaderPath = path
|
||||
}
|
||||
|
||||
var instance *config
|
||||
|
||||
func Instance() *config {
|
||||
if instance == nil {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if instance == nil {
|
||||
instance = &config{serverConfig{}}
|
||||
}
|
||||
}
|
||||
return instance
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/marcopeocchi/fazzoletti/slices"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
|
||||
)
|
||||
|
||||
@@ -23,7 +25,9 @@ const template = `download:
|
||||
"speed":%(progress.speed)s
|
||||
}`
|
||||
|
||||
const driver = "yt-dlp"
|
||||
var (
|
||||
cfg = config.Instance()
|
||||
)
|
||||
|
||||
type ProgressTemplate struct {
|
||||
Percentage string `json:"percentage"`
|
||||
@@ -43,12 +47,17 @@ type Process struct {
|
||||
proc *os.Process
|
||||
}
|
||||
|
||||
type downloadOutput struct {
|
||||
path string
|
||||
filaneme string
|
||||
}
|
||||
|
||||
// Starts spawns/forks a new yt-dlp process and parse its stdout.
|
||||
// The process is spawned to outputting a custom progress text that
|
||||
// Resembles a JSON Object in order to Unmarshal it later.
|
||||
// This approach is anyhow not perfect: quotes are not escaped properly.
|
||||
// Each process is not identified by its PID but by a UUIDv2
|
||||
func (p *Process) Start() {
|
||||
func (p *Process) Start(path, filename string) {
|
||||
// escape bash variable escaping and command piping, you'll never know
|
||||
// what they might come with...
|
||||
p.params = slices.Filter(p.params, func(e string) bool {
|
||||
@@ -56,6 +65,18 @@ func (p *Process) Start() {
|
||||
return !match
|
||||
})
|
||||
|
||||
out := downloadOutput{
|
||||
path: cfg.GetConfig().DownloadPath,
|
||||
filaneme: "%(title)s.%(ext)s",
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
out.path = path
|
||||
}
|
||||
if filename != "" {
|
||||
out.filaneme = filename + ".%(ext)s"
|
||||
}
|
||||
|
||||
params := append([]string{
|
||||
strings.Split(p.url, "?list")[0], //no playlist
|
||||
"--newline",
|
||||
@@ -63,11 +84,11 @@ func (p *Process) Start() {
|
||||
"--no-playlist",
|
||||
"--progress-template", strings.ReplaceAll(template, "\n", ""),
|
||||
"-o",
|
||||
"./downloads/%(title)s.%(ext)s",
|
||||
fmt.Sprintf("%s/%s", out.path, out.filaneme),
|
||||
}, p.params...)
|
||||
|
||||
// ----------------- main block ----------------- //
|
||||
cmd := exec.Command(driver, params...)
|
||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, params...)
|
||||
r, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Panicln(err)
|
||||
@@ -85,7 +106,7 @@ func (p *Process) Start() {
|
||||
// ----------------- info block ----------------- //
|
||||
// spawn a goroutine that retrieves the info for the download
|
||||
go func() {
|
||||
cmd := exec.Command(driver, p.url, "-J")
|
||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.url, "-J")
|
||||
stdout, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Println("Cannot retrieve info for", p.url)
|
||||
@@ -100,18 +121,19 @@ func (p *Process) Start() {
|
||||
eventChan := make(chan string)
|
||||
|
||||
// spawn a goroutine that does the dirty job of parsing the stdout
|
||||
// fill the channel with as many stdout line as yt-dlp produces (producer)
|
||||
// filling the channel with as many stdout line as yt-dlp produces (producer)
|
||||
go func() {
|
||||
defer cmd.Wait()
|
||||
defer r.Close()
|
||||
defer p.Complete()
|
||||
defer close(eventChan)
|
||||
for scan.Scan() {
|
||||
eventChan <- scan.Text()
|
||||
}
|
||||
cmd.Wait()
|
||||
}()
|
||||
|
||||
// do the unmarshal operation every 500ms (consumer)
|
||||
go rx.Sample(time.Millisecond*500, eventChan, func(text string) {
|
||||
go rx.Debounce(time.Millisecond*500, eventChan, func(text string) {
|
||||
stdout := ProgressTemplate{}
|
||||
err := json.Unmarshal([]byte(text), &stdout)
|
||||
if err == nil {
|
||||
@@ -147,7 +169,7 @@ func (p *Process) Kill() error {
|
||||
}
|
||||
|
||||
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
||||
cmd := exec.Command(driver, p.url, "-J")
|
||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.url, "-J")
|
||||
stdout, err := cmd.Output()
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -23,7 +23,7 @@ func init() {
|
||||
|
||||
func RunBlocking(ctx context.Context) {
|
||||
fe := ctx.Value("frontend").(fs.SubFS)
|
||||
port := ctx.Value("port")
|
||||
port := ctx.Value("port").(int)
|
||||
|
||||
service := new(Service)
|
||||
rpc.Register(service)
|
||||
@@ -62,5 +62,5 @@ func RunBlocking(ctx context.Context) {
|
||||
|
||||
app.Server().StreamRequestBody = true
|
||||
|
||||
log.Fatal(app.Listen(fmt.Sprintf(":%s", port)))
|
||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater"
|
||||
)
|
||||
|
||||
type Service int
|
||||
@@ -12,18 +13,27 @@ type Running []ProcessResponse
|
||||
type Pending []string
|
||||
|
||||
type NoArgs struct{}
|
||||
|
||||
type Args struct {
|
||||
Id string
|
||||
URL string
|
||||
Params []string
|
||||
}
|
||||
|
||||
type DownloadSpecificArgs struct {
|
||||
Id string
|
||||
URL string
|
||||
Path string
|
||||
Rename string
|
||||
Params []string
|
||||
}
|
||||
|
||||
// Exec spawns a Process.
|
||||
// The result of the execution is the newly spawned process Id.
|
||||
func (t *Service) Exec(args Args, result *string) error {
|
||||
func (t *Service) Exec(args DownloadSpecificArgs, result *string) error {
|
||||
log.Printf("Spawning new process for %s\n", args.URL)
|
||||
p := Process{mem: &db, url: args.URL, params: args.Params}
|
||||
p.Start()
|
||||
p.Start(args.Path, args.Rename)
|
||||
*result = p.id
|
||||
return nil
|
||||
}
|
||||
@@ -86,7 +96,17 @@ func (t *Service) FreeSpace(args NoArgs, free *uint64) error {
|
||||
}
|
||||
|
||||
func (t *Service) DirectoryTree(args NoArgs, tree *[]string) error {
|
||||
dfsTree, err := sys.DirectoryTree("downloads")
|
||||
dfsTree, err := sys.DirectoryTree()
|
||||
*tree = *dfsTree
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Service) UpdateExecutable(args NoArgs, updated *bool) error {
|
||||
err := updater.UpdateExecutable()
|
||||
if err != nil {
|
||||
*updated = true
|
||||
return err
|
||||
}
|
||||
*updated = false
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
@@ -13,20 +14,20 @@ import (
|
||||
// FreeSpace gets the available Bytes writable to download directory
|
||||
func FreeSpace() (uint64, error) {
|
||||
var stat unix.Statfs_t
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
unix.Statfs(wd+"/downloads", &stat)
|
||||
unix.Statfs(config.Instance().GetConfig().DownloadPath, &stat)
|
||||
return (stat.Bavail * uint64(stat.Bsize)), nil
|
||||
}
|
||||
|
||||
func DirectoryTree(rootPath string) (*[]string, error) {
|
||||
// Build a directory tree started from the specified path using DFS.
|
||||
// Then return the flattened tree represented as a list.
|
||||
func DirectoryTree() (*[]string, error) {
|
||||
type Node struct {
|
||||
path string
|
||||
children []Node
|
||||
}
|
||||
|
||||
rootPath := config.Instance().GetConfig().DownloadPath
|
||||
|
||||
stack := internal.Stack[Node]{
|
||||
Nodes: make([]*internal.Node[Node], 5),
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ type AbortRequest struct {
|
||||
|
||||
// struct representing the intent to start a download
|
||||
type DownloadRequest struct {
|
||||
Url string `json:"url"`
|
||||
Params []string `json:"params"`
|
||||
Url string `json:"url"`
|
||||
Params []string `json:"params"`
|
||||
RenameTo string `json:"renameTo"`
|
||||
}
|
||||
|
||||
45
server/updater/forced_update.go
Normal file
45
server/updater/forced_update.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
const (
|
||||
gitHubAPILatest = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest"
|
||||
gitHubAPIDownload = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/download"
|
||||
)
|
||||
|
||||
var (
|
||||
client = &http.Client{
|
||||
CheckRedirect: http.DefaultClient.CheckRedirect,
|
||||
}
|
||||
)
|
||||
|
||||
func getLatestReleaseTag() (string, error) {
|
||||
res, err := client.Get(gitHubAPILatest)
|
||||
if err != nil {
|
||||
log.Println("Cannot get release tag from GitHub API")
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
log.Println("Cannot parse response from GitHub API")
|
||||
return "", err
|
||||
}
|
||||
|
||||
tag := ReleaseLatestResponse{}
|
||||
json.Unmarshal(body, &tag)
|
||||
|
||||
return tag.TagName, nil
|
||||
}
|
||||
|
||||
func ForceUpdate() {
|
||||
getLatestReleaseTag()
|
||||
}
|
||||
6
server/updater/types.go
Normal file
6
server/updater/types.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package updater
|
||||
|
||||
type ReleaseLatestResponse struct {
|
||||
Name string `json:"name"`
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
17
server/updater/update.go
Normal file
17
server/updater/update.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
)
|
||||
|
||||
var path = config.Instance().GetConfig().DownloaderPath
|
||||
|
||||
func UpdateExecutable() error {
|
||||
cmd := exec.Command(path, "-U")
|
||||
cmd.Start()
|
||||
|
||||
err := cmd.Wait()
|
||||
return err
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"port": 0,
|
||||
"download_path": "",
|
||||
"cliArgs": []
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"target": "ES2020",
|
||||
"strict": false,
|
||||
"noEmit": false,
|
||||
"moduleResolution": "node",
|
||||
"module": "commonjs",
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
"include": [
|
||||
"server-node/src/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user