Merge pull request #25 from marcopeocchi/golang-backend-migration
Golang backend migration
This commit is contained in:
@@ -11,4 +11,5 @@ src/server/core/yt-dlp
|
|||||||
.env
|
.env
|
||||||
*.mp4
|
*.mp4
|
||||||
*.ytdl
|
*.ytdl
|
||||||
*.db
|
*.db
|
||||||
|
build/
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,4 +12,6 @@ src/server/core/yt-dlp
|
|||||||
*.part
|
*.part
|
||||||
*.db
|
*.db
|
||||||
downloads
|
downloads
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
build/
|
||||||
|
yt-dlp-webui
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -1,18 +1,20 @@
|
|||||||
FROM node:18-alpine
|
FROM alpine:3.17
|
||||||
RUN mkdir -p /usr/src/yt-dlp-webui/download
|
# folder structure
|
||||||
|
WORKDIR /usr/src/yt-dlp-webui/downloads
|
||||||
VOLUME /usr/src/yt-dlp-webui/downloads
|
VOLUME /usr/src/yt-dlp-webui/downloads
|
||||||
WORKDIR /usr/src/yt-dlp-webui
|
WORKDIR /usr/src/yt-dlp-webui
|
||||||
COPY package*.json ./
|
|
||||||
# install core dependencies
|
# install core dependencies
|
||||||
RUN apk update
|
RUN apk update
|
||||||
RUN apk add curl wget psmisc python3 ffmpeg
|
RUN apk add curl wget psmisc python3 ffmpeg nodejs go yt-dlp
|
||||||
|
# copy srcs
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN chmod +x ./fetch-yt-dlp.sh
|
|
||||||
# install node dependencies
|
# install node dependencies
|
||||||
|
WORKDIR /usr/src/yt-dlp-webui/frontend
|
||||||
RUN npm i
|
RUN npm i
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
RUN npm run build-server
|
# install go dependencies
|
||||||
RUN npm run fetch
|
WORKDIR /usr/src/yt-dlp-webui
|
||||||
|
RUN npm go build -o yt-dlp-webui
|
||||||
# expose and run
|
# expose and run
|
||||||
EXPOSE 3022
|
EXPOSE 3033
|
||||||
CMD [ "node" , "./dist/main.js" ]
|
CMD [ "yt-dlp-webui" , "--out", "./downloads" ]
|
||||||
|
|||||||
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>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./index.tsx"></script>
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -3,17 +3,8 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"description": "A terrible webUI for yt-dlp, all-in-one solution.",
|
"description": "A terrible webUI for yt-dlp, all-in-one solution.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon dist/main.js",
|
"dev": "vite",
|
||||||
"start": "node dist/main.js",
|
"build": "vite build"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"author": "marcobaobao",
|
"author": "marcobaobao",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -24,25 +15,17 @@
|
|||||||
"@mui/icons-material": "^5.6.2",
|
"@mui/icons-material": "^5.6.2",
|
||||||
"@mui/material": "^5.6.4",
|
"@mui/material": "^5.6.4",
|
||||||
"@reduxjs/toolkit": "^1.8.1",
|
"@reduxjs/toolkit": "^1.8.1",
|
||||||
"koa": "^2.13.4",
|
"radash": "^10.6.0",
|
||||||
"koa-router": "^10.1.1",
|
|
||||||
"koa-static": "^5.0.0",
|
|
||||||
"mime-types": "^2.1.35",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-redux": "^8.0.1",
|
"react-redux": "^8.0.1",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"rxjs": "^7.4.0",
|
"rxjs": "^7.4.0",
|
||||||
"socket.io": "^4.3.2",
|
|
||||||
"socket.io-client": "^4.3.2",
|
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modyfi/vite-plugin-yaml": "^1.0.2",
|
"@modyfi/vite-plugin-yaml": "^1.0.2",
|
||||||
"@types/koa": "^2.13.4",
|
"@types/node": "^18.11.18",
|
||||||
"@types/koa-router": "^7.4.4",
|
|
||||||
"@types/mime-types": "^2.1.1",
|
|
||||||
"@types/node": "^17.0.31",
|
|
||||||
"@types/react": "^18.0.21",
|
"@types/react": "^18.0.21",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
@@ -16,35 +16,29 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { grey } from "@mui/material/colors";
|
import { grey } from "@mui/material/colors";
|
||||||
|
|
||||||
|
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
import {
|
import {
|
||||||
BrowserRouter as Router, Link, Route,
|
BrowserRouter as Router, Link, Route,
|
||||||
Routes
|
Routes
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import ArchivedDownloads from "./Archived";
|
|
||||||
import { AppBar } from "./components/AppBar";
|
import { AppBar } from "./components/AppBar";
|
||||||
import { Drawer } from "./components/Drawer";
|
import { Drawer } from "./components/Drawer";
|
||||||
import Home from "./Home";
|
import Home from "./Home";
|
||||||
import Settings from "./Settings";
|
import Settings from "./Settings";
|
||||||
import { RootState, store } from './stores/store';
|
import { RootState, store } from './stores/store';
|
||||||
import { getWebSocketEndpoint } from "./utils";
|
import { formatGiB, getWebSocketEndpoint } from "./utils";
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false)
|
||||||
const [freeDiskSpace, setFreeDiskSpace] = useState('');
|
|
||||||
|
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const settings = useSelector((state: RootState) => state.settings)
|
||||||
const status = useSelector((state: RootState) => state.status)
|
const status = useSelector((state: RootState) => state.status)
|
||||||
|
|
||||||
const socket = useMemo(() => io(getWebSocketEndpoint()), [])
|
const socket = useMemo(() => new WebSocket(getWebSocketEndpoint()), [])
|
||||||
|
|
||||||
const mode = settings.theme
|
const mode = settings.theme
|
||||||
|
|
||||||
const theme = useMemo(() =>
|
const theme = useMemo(() =>
|
||||||
createTheme({
|
createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
@@ -54,18 +48,11 @@ function AppContent() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}), [settings.theme]
|
}), [settings.theme]
|
||||||
);
|
)
|
||||||
|
|
||||||
const toggleDrawer = () => {
|
const toggleDrawer = () => {
|
||||||
setOpen(!open);
|
setOpen(!open)
|
||||||
};
|
}
|
||||||
|
|
||||||
/* Get disk free space */
|
|
||||||
useEffect(() => {
|
|
||||||
socket.on('free-space', (res: string) => {
|
|
||||||
setFreeDiskSpace(res)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
@@ -73,11 +60,7 @@ function AppContent() {
|
|||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<AppBar position="absolute" open={open}>
|
<AppBar position="absolute" open={open}>
|
||||||
<Toolbar
|
<Toolbar sx={{ pr: '24px' }}>
|
||||||
sx={{
|
|
||||||
pr: '24px', // keep right padding when drawer closed
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
edge="start"
|
edge="start"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
@@ -100,14 +83,14 @@ function AppContent() {
|
|||||||
yt-dlp WebUI
|
yt-dlp WebUI
|
||||||
</Typography>
|
</Typography>
|
||||||
{
|
{
|
||||||
freeDiskSpace ?
|
status.freeSpace ?
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
}}>
|
}}>
|
||||||
<Storage />
|
<Storage />
|
||||||
<span> {freeDiskSpace} </span>
|
<span> {formatGiB(status.freeSpace)} </span>
|
||||||
</div>
|
</div>
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
@@ -149,20 +132,6 @@ function AppContent() {
|
|||||||
<ListItemText primary="Home" />
|
<ListItemText primary="Home" />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
{/* Next release: list downloaded files */}
|
|
||||||
{/* <Link to={'/downloaded'} style={
|
|
||||||
{
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
<ListItemButton disabled={status.downloading}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Download />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Downloaded" />
|
|
||||||
</ListItemButton>
|
|
||||||
</Link> */}
|
|
||||||
<Link to={'/settings'} style={
|
<Link to={'/settings'} style={
|
||||||
{
|
{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -188,9 +157,8 @@ function AppContent() {
|
|||||||
>
|
>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home socket={socket}></Home>}></Route>
|
<Route path="/" element={<Home socket={socket} />} />
|
||||||
<Route path="/settings" element={<Settings socket={socket}></Settings>}></Route>
|
<Route path="/settings" element={<Settings socket={socket} />} />
|
||||||
<Route path="/downloaded" element={<ArchivedDownloads></ArchivedDownloads>}></Route>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -202,7 +170,7 @@ function AppContent() {
|
|||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<AppContent></AppContent>
|
<AppContent />
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,492 +1,488 @@
|
|||||||
import { FileUpload } from "@mui/icons-material";
|
import { FileUpload } from "@mui/icons-material";
|
||||||
import {
|
import {
|
||||||
Backdrop,
|
Backdrop,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
FormControl,
|
FormControl,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
styled,
|
styled,
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { Socket } from "socket.io-client";
|
|
||||||
import { CliArguments } from "./classes";
|
|
||||||
import { StackableResult } from "./components/StackableResult";
|
import { StackableResult } from "./components/StackableResult";
|
||||||
import { serverStates } from "./events";
|
import { CliArguments } from "./features/core/argsParser";
|
||||||
import { connected, downloading, finished } from "./features/status/statusSlice";
|
import I18nBuilder from "./features/core/intl";
|
||||||
import { I18nBuilder } from "./i18n";
|
import { RPCClient } from "./features/core/rpcClient";
|
||||||
import { IDLMetadata, IDLMetadataAndPID, IMessage } from "./interfaces";
|
import { connected, setFreeSpace } from "./features/status/statusSlice";
|
||||||
import { RootState } from "./stores/store";
|
import { RootState } from "./stores/store";
|
||||||
import { isValidURL, toFormatArgs, updateInStateMap } from "./utils";
|
import { IDLMetadata, RPCResult } from "./types";
|
||||||
|
import { isValidURL, toFormatArgs } from "./utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
socket: Socket
|
socket: WebSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ socket }: Props) {
|
export default function Home({ socket }: Props) {
|
||||||
// redux state
|
// redux state
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const settings = useSelector((state: RootState) => state.settings)
|
||||||
const status = useSelector((state: RootState) => state.status)
|
const status = useSelector((state: RootState) => state.status)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
// ephemeral state
|
// ephemeral state
|
||||||
const [progressMap, setProgressMap] = useState(new Map<number, number>());
|
const [activeDownloads, setActiveDownloads] = useState(new Array<RPCResult>());
|
||||||
const [messageMap, setMessageMap] = useState(new Map<number, IMessage>());
|
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
|
||||||
const [downloadInfoMap, setDownloadInfoMap] = useState(new Map<number, IDLMetadata>());
|
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
|
||||||
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
|
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
|
||||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
|
const [pickedBestFormat, setPickedBestFormat] = useState('');
|
||||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
|
|
||||||
const [pickedBestFormat, setPickedBestFormat] = useState('');
|
|
||||||
|
|
||||||
const [downloadPath, setDownloadPath] = useState(0);
|
const [customArgs, setCustomArgs] = useState('');
|
||||||
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([]);
|
const [downloadPath, setDownloadPath] = useState(0);
|
||||||
|
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([]);
|
||||||
|
|
||||||
const [fileNameOverride, setFilenameOverride] = useState('');
|
const [fileNameOverride, setFilenameOverride] = useState('');
|
||||||
|
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [workingUrl, setWorkingUrl] = useState('');
|
const [workingUrl, setWorkingUrl] = useState('');
|
||||||
const [showBackdrop, setShowBackdrop] = useState(false);
|
|
||||||
const [showToast, setShowToast] = useState(true);
|
|
||||||
|
|
||||||
// memos
|
const [showBackdrop, setShowBackdrop] = useState(false);
|
||||||
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
const [showToast, setShowToast] = useState(true);
|
||||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
|
||||||
|
|
||||||
/* -------------------- Effects -------------------- */
|
// memos
|
||||||
/* WebSocket connect event handler*/
|
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||||
useEffect(() => {
|
const client = useMemo(() => new RPCClient(socket), [settings.serverAddr, settings.serverPort])
|
||||||
socket.on('connect', () => {
|
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
||||||
dispatch(connected())
|
|
||||||
socket.emit('fetch-jobs')
|
|
||||||
socket.emit('disk-space')
|
|
||||||
socket.emit('retrieve-jobs')
|
|
||||||
});
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* Ask server for pending jobs / background jobs */
|
/* -------------------- Effects -------------------- */
|
||||||
useEffect(() => {
|
/* WebSocket connect event handler*/
|
||||||
socket.on('pending-jobs', (count: number) => {
|
useEffect(() => {
|
||||||
count === 0 ? setShowBackdrop(false) : setShowBackdrop(true)
|
socket.onopen = () => {
|
||||||
})
|
dispatch(connected())
|
||||||
}, [])
|
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
|
||||||
|
|
||||||
/* Handle download information sent by server */
|
|
||||||
useEffect(() => {
|
|
||||||
socket.on('available-formats', (data: IDLMetadata) => {
|
|
||||||
setShowBackdrop(false)
|
|
||||||
setDownloadFormats(data);
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* Handle download information sent by server */
|
|
||||||
useEffect(() => {
|
|
||||||
socket.on('metadata', (data: IDLMetadataAndPID) => {
|
|
||||||
setShowBackdrop(false)
|
|
||||||
dispatch(downloading())
|
|
||||||
updateInStateMap<number, IDLMetadata>(data.pid, data.metadata, downloadInfoMap, setDownloadInfoMap);
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* Handle per-download progress */
|
|
||||||
useEffect(() => {
|
|
||||||
socket.on('progress', (data: IMessage) => {
|
|
||||||
if (data.status === serverStates.PROG_DONE || data.status === serverStates.PROC_ABORT) {
|
|
||||||
setShowBackdrop(false)
|
|
||||||
updateInStateMap<number, IMessage>(data.pid, serverStates.PROG_DONE, messageMap, setMessageMap);
|
|
||||||
updateInStateMap<number, number>(data.pid, 0, progressMap, setProgressMap);
|
|
||||||
socket.emit('disk-space')
|
|
||||||
dispatch(finished())
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateInStateMap<number, IMessage>(data.pid, data, messageMap, setMessageMap);
|
|
||||||
if (data.progress) {
|
|
||||||
updateInStateMap<number, number>(data.pid,
|
|
||||||
Math.ceil(Number(data.progress.replace('%', ''))),
|
|
||||||
progressMap,
|
|
||||||
setProgressMap
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${window.location.protocol}//${settings.serverAddr}:${settings.serverPort}/tree`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
setAvailableDownloadPaths(data.flat)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* -------------------- component functions -------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
|
||||||
*/
|
|
||||||
const sendUrl = (immediate?: string) => {
|
|
||||||
const codes = new Array<string>();
|
|
||||||
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat);
|
|
||||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat);
|
|
||||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat);
|
|
||||||
|
|
||||||
socket.emit('send-url', {
|
|
||||||
url: immediate || url || workingUrl,
|
|
||||||
path: availableDownloadPaths[downloadPath],
|
|
||||||
params: cliArgs.toString() + toFormatArgs(codes),
|
|
||||||
renameTo: fileNameOverride,
|
|
||||||
})
|
|
||||||
|
|
||||||
setUrl('')
|
|
||||||
setWorkingUrl('')
|
|
||||||
setFilenameOverride('')
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
resetInput()
|
|
||||||
setShowBackdrop(true)
|
|
||||||
setDownloadFormats(undefined)
|
|
||||||
}, 250);
|
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
useEffect(() => {
|
||||||
* Retrive url from input and display the formats selection view
|
if (status.connected) {
|
||||||
*/
|
client.running()
|
||||||
const sendUrlFormatSelection = () => {
|
const interval = setInterval(() => client.running(), 1000)
|
||||||
socket.emit('send-url-format-selection', {
|
return () => clearInterval(interval)
|
||||||
url: url,
|
|
||||||
})
|
|
||||||
|
|
||||||
setWorkingUrl(url)
|
|
||||||
setUrl('')
|
|
||||||
setPickedAudioFormat('')
|
|
||||||
setPickedVideoFormat('')
|
|
||||||
setPickedBestFormat('')
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
resetInput()
|
|
||||||
setShowBackdrop(true)
|
|
||||||
}, 250)
|
|
||||||
}
|
}
|
||||||
|
}, [status.connected])
|
||||||
|
|
||||||
/**
|
useEffect(() => {
|
||||||
* Update the url state whenever the input value changes
|
client.freeSpace()
|
||||||
* @param e Input change event
|
.then(bytes => dispatch(setFreeSpace(bytes.result)))
|
||||||
*/
|
}, [])
|
||||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUrl(e.target.value)
|
useEffect(() => {
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const res = client.decode(event.data)
|
||||||
|
switch (typeof res.result) {
|
||||||
|
case 'object':
|
||||||
|
setActiveDownloads(
|
||||||
|
(res.result ?? [])
|
||||||
|
.filter((r: RPCResult) => !!r.info.url)
|
||||||
|
.sort((a: RPCResult, b: RPCResult) => a.info.title.localeCompare(b.info.title))
|
||||||
|
)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
useEffect(() => {
|
||||||
* Update the filename override state whenever the input value changes
|
if (activeDownloads.length > 0 && showBackdrop) {
|
||||||
* @param e Input change event
|
setShowBackdrop(false)
|
||||||
*/
|
|
||||||
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setFilenameOverride(e.target.value)
|
|
||||||
}
|
}
|
||||||
|
}, [activeDownloads, showBackdrop])
|
||||||
|
|
||||||
/**
|
useEffect(() => {
|
||||||
* Abort a specific download if id's provided, other wise abort all running ones.
|
client.directoryTree()
|
||||||
* @param id The download id / pid
|
.then(data => {
|
||||||
* @returns void
|
setAvailableDownloadPaths(data.result)
|
||||||
*/
|
})
|
||||||
const abort = (id?: number) => {
|
}, [])
|
||||||
if (id) {
|
|
||||||
updateInStateMap(id, null, downloadInfoMap, setDownloadInfoMap, true)
|
/* -------------------- component functions -------------------- */
|
||||||
socket.emit('abort', { pid: id })
|
|
||||||
return
|
/**
|
||||||
}
|
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||||
setDownloadFormats(undefined)
|
*/
|
||||||
socket.emit('abort-all')
|
const sendUrl = (immediate?: string) => {
|
||||||
|
const codes = new Array<string>();
|
||||||
|
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat);
|
||||||
|
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat);
|
||||||
|
if (pickedBestFormat !== '') codes.push(pickedBestFormat);
|
||||||
|
|
||||||
|
client.download(
|
||||||
|
immediate || url || workingUrl,
|
||||||
|
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
|
||||||
|
availableDownloadPaths[downloadPath] ?? '',
|
||||||
|
fileNameOverride
|
||||||
|
)
|
||||||
|
|
||||||
|
setUrl('')
|
||||||
|
setWorkingUrl('')
|
||||||
|
setFilenameOverride('')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resetInput()
|
||||||
|
setShowBackdrop(true)
|
||||||
|
setDownloadFormats(undefined)
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrive url from input and display the formats selection view
|
||||||
|
*/
|
||||||
|
const sendUrlFormatSelection = () => {
|
||||||
|
setWorkingUrl(url)
|
||||||
|
setUrl('')
|
||||||
|
setPickedAudioFormat('')
|
||||||
|
setPickedVideoFormat('')
|
||||||
|
setPickedBestFormat('')
|
||||||
|
|
||||||
|
setShowBackdrop(true)
|
||||||
|
|
||||||
|
client.formats(url)
|
||||||
|
?.then(formats => {
|
||||||
|
setDownloadFormats(formats.result)
|
||||||
|
setShowBackdrop(false)
|
||||||
|
resetInput()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the url state whenever the input value changes
|
||||||
|
* @param e Input change event
|
||||||
|
*/
|
||||||
|
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setUrl(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the filename override state whenever the input value changes
|
||||||
|
* @param e Input change event
|
||||||
|
*/
|
||||||
|
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFilenameOverride(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the custom args state whenever the input value changes
|
||||||
|
* @param e Input change event
|
||||||
|
*/
|
||||||
|
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setCustomArgs(e.target.value)
|
||||||
|
localStorage.setItem("last-input-args", e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort a specific download if id's provided, other wise abort all running ones.
|
||||||
|
* @param id The download id / pid
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
const abort = (id?: string) => {
|
||||||
|
if (id) {
|
||||||
|
client.kill(id)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
client.killAll()
|
||||||
|
}
|
||||||
|
|
||||||
const parseUrlListFile = (event: any) => {
|
const parseUrlListFile = (event: any) => {
|
||||||
const urlList = event.target.files
|
const urlList = event.target.files
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.addEventListener('load', $event => {
|
reader.addEventListener('load', $event => {
|
||||||
const base64 = $event.target?.result!.toString().split(',')[1]
|
const base64 = $event.target?.result!.toString().split(',')[1]
|
||||||
Buffer.from(base64!, 'base64')
|
Buffer.from(base64!, 'base64')
|
||||||
.toString()
|
.toString()
|
||||||
.trimEnd()
|
.trimEnd()
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter(_url => isValidURL(_url))
|
.filter(_url => isValidURL(_url))
|
||||||
.forEach(_url => sendUrl(_url))
|
.forEach(_url => sendUrl(_url))
|
||||||
})
|
})
|
||||||
reader.readAsDataURL(urlList[0])
|
reader.readAsDataURL(urlList[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetInput = () => {
|
||||||
|
const input = document.getElementById('urlInput') as HTMLInputElement;
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
const filename = document.getElementById('customFilenameInput') as HTMLInputElement;
|
||||||
|
if (filename) {
|
||||||
|
filename.value = '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resetInput = () => {
|
/* -------------------- styled components -------------------- */
|
||||||
const input = document.getElementById('urlInput') as HTMLInputElement;
|
|
||||||
input.value = '';
|
|
||||||
|
|
||||||
const filename = document.getElementById('customFilenameInput') as HTMLInputElement;
|
const Input = styled('input')({
|
||||||
if (filename) {
|
display: 'none',
|
||||||
filename.value = '';
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------- styled components -------------------- */
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
const Input = styled('input')({
|
<Backdrop
|
||||||
display: 'none',
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||||
});
|
open={showBackdrop}
|
||||||
|
>
|
||||||
return (
|
<CircularProgress color="primary" />
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
</Backdrop>
|
||||||
<Backdrop
|
<Grid container spacing={2}>
|
||||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
<Grid item xs={12}>
|
||||||
open={showBackdrop}
|
<Paper
|
||||||
>
|
sx={{
|
||||||
<CircularProgress color="primary" />
|
p: 2,
|
||||||
</Backdrop>
|
display: 'flex',
|
||||||
<Grid container spacing={2}>
|
flexDirection: 'column',
|
||||||
<Grid item xs={12}>
|
}}
|
||||||
<Paper
|
>
|
||||||
sx={{
|
<Grid container>
|
||||||
p: 2,
|
<TextField
|
||||||
display: 'flex',
|
fullWidth
|
||||||
flexDirection: 'column',
|
id="urlInput"
|
||||||
}}
|
label={i18n.t('urlInput')}
|
||||||
>
|
variant="outlined"
|
||||||
<Grid container>
|
onChange={handleUrlChange}
|
||||||
<TextField
|
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||||
fullWidth
|
InputProps={{
|
||||||
id="urlInput"
|
endAdornment: (
|
||||||
label={i18n.t('urlInput')}
|
<InputAdornment position="end">
|
||||||
variant="outlined"
|
<label htmlFor="icon-button-file">
|
||||||
onChange={handleUrlChange}
|
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} />
|
||||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
<IconButton color="primary" aria-label="upload file" component="span">
|
||||||
InputProps={{
|
<FileUpload />
|
||||||
endAdornment: (
|
</IconButton>
|
||||||
<InputAdornment position="end">
|
</label>
|
||||||
<label htmlFor="icon-button-file">
|
</InputAdornment>
|
||||||
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} />
|
),
|
||||||
<IconButton color="primary" aria-label="upload file" component="span">
|
}}
|
||||||
<FileUpload />
|
/>
|
||||||
</IconButton>
|
|
||||||
</label>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
|
||||||
{
|
|
||||||
settings.fileRenaming ?
|
|
||||||
<Grid item xs={8}>
|
|
||||||
<TextField
|
|
||||||
id="customFilenameInput"
|
|
||||||
fullWidth
|
|
||||||
label={i18n.t('customFilename')}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={handleFilenameOverrideChange}
|
|
||||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
|
||||||
/>
|
|
||||||
</Grid> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
{
|
|
||||||
settings.pathOverriding ?
|
|
||||||
<Grid item xs={4}>
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>{i18n.t('customPath')}</InputLabel>
|
|
||||||
<Select
|
|
||||||
label={i18n.t('customPath')}
|
|
||||||
defaultValue={0}
|
|
||||||
variant={'outlined'}
|
|
||||||
value={downloadPath}
|
|
||||||
onChange={(e) => setDownloadPath(Number(e.target.value))}
|
|
||||||
>
|
|
||||||
{availableDownloadPaths.map((val: string, idx: number) => (
|
|
||||||
<MenuItem key={idx} value={idx}>{val}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</Grid>
|
|
||||||
<Grid container spacing={1} pt={2}>
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={url === ''}
|
|
||||||
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
|
|
||||||
>
|
|
||||||
{i18n.t('startButton')}
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => abort()}
|
|
||||||
>
|
|
||||||
{i18n.t('abortAllButton')}
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Paper>
|
|
||||||
</Grid>
|
|
||||||
</Grid >
|
|
||||||
{/* Format Selection grid */}
|
|
||||||
{
|
|
||||||
downloadFormats ? <Grid container spacing={2} mt={2}>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid container>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Typography variant="h6" component="div" pb={1}>
|
|
||||||
{downloadFormats.title}
|
|
||||||
</Typography>
|
|
||||||
{/* <Skeleton variant="rectangular" height={180} /> */}
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} pb={1}>
|
|
||||||
<img src={downloadFormats.thumbnail} height={260} width="100%" style={{ objectFit: 'cover' }} />
|
|
||||||
</Grid>
|
|
||||||
{/* video only */}
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Typography variant="body1" component="div">
|
|
||||||
Best quality
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid item pr={2} py={1}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={pickedBestFormat !== ''}
|
|
||||||
onClick={() => {
|
|
||||||
setPickedBestFormat(downloadFormats.best.format_id)
|
|
||||||
setPickedVideoFormat('')
|
|
||||||
setPickedAudioFormat('')
|
|
||||||
}}>
|
|
||||||
{downloadFormats.best.format_note || downloadFormats.best.format_id} - {downloadFormats.best.vcodec}+{downloadFormats.best.acodec}
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
{/* video only */}
|
|
||||||
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Typography variant="body1" component="div">
|
|
||||||
Video data {downloadFormats.formats[1].acodec}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
{downloadFormats.formats
|
|
||||||
.filter(format => format.acodec === 'none' && format.vcodec !== 'none')
|
|
||||||
.map((format, idx) => (
|
|
||||||
<Grid item pr={2} py={1} key={idx}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => {
|
|
||||||
setPickedVideoFormat(format.format_id)
|
|
||||||
setPickedBestFormat('')
|
|
||||||
}}
|
|
||||||
disabled={pickedVideoFormat === format.format_id}
|
|
||||||
>
|
|
||||||
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Typography variant="body1" component="div">
|
|
||||||
Audio data
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
{downloadFormats.formats
|
|
||||||
.filter(format => format.acodec !== 'none' && format.vcodec === 'none')
|
|
||||||
.map((format, idx) => (
|
|
||||||
<Grid item pr={2} py={1} key={idx}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => {
|
|
||||||
setPickedAudioFormat(format.format_id)
|
|
||||||
setPickedBestFormat('')
|
|
||||||
}}
|
|
||||||
disabled={pickedAudioFormat === format.format_id}
|
|
||||||
>
|
|
||||||
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
<Grid item xs={12} pt={2}>
|
|
||||||
<ButtonGroup disableElevation variant="contained">
|
|
||||||
<Button
|
|
||||||
onClick={() => sendUrl()}
|
|
||||||
disabled={!pickedBestFormat && !(pickedAudioFormat || pickedVideoFormat)}
|
|
||||||
> Download
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setPickedAudioFormat('');
|
|
||||||
setPickedVideoFormat('');
|
|
||||||
setPickedBestFormat('');
|
|
||||||
}}
|
|
||||||
> Clear
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Paper>
|
|
||||||
</Grid>
|
|
||||||
</Grid> : null
|
|
||||||
}
|
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
|
||||||
{
|
|
||||||
Array
|
|
||||||
.from<any>(messageMap)
|
|
||||||
.filter(flattened => [...flattened][0])
|
|
||||||
.filter(flattened => [...flattened][1].toString() !== serverStates.PROG_DONE)
|
|
||||||
.flatMap(message => (
|
|
||||||
<Grid item xs={4} sm={8} md={6} key={message[0]}>
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
Message[0] => key, the pid which is shared with the progress and download Maps
|
|
||||||
Message[1] => value, the actual formatted message sent from server
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
<Fragment>
|
|
||||||
<StackableResult
|
|
||||||
formattedLog={message[1]}
|
|
||||||
title={downloadInfoMap.get(message[0])?.title ?? ''}
|
|
||||||
thumbnail={downloadInfoMap.get(message[0])?.thumbnail ?? ''}
|
|
||||||
progress={progressMap.get(message[0]) ?? 0}
|
|
||||||
stopCallback={() => abort(message[0])}
|
|
||||||
resolution={
|
|
||||||
settings.formatSelection
|
|
||||||
? ''
|
|
||||||
: downloadInfoMap.get(message[0])?.best.resolution ?? ''
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
</Grid>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<Snackbar
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||||
open={showToast === status.connected}
|
{
|
||||||
autoHideDuration={1500}
|
settings.enableCustomArgs ?
|
||||||
message="Connected"
|
<Grid item xs={12}>
|
||||||
onClose={() => setShowToast(false)}
|
<TextField
|
||||||
/>
|
id="customArgsInput"
|
||||||
</Container >
|
fullWidth
|
||||||
);
|
label={i18n.t('customArgsInput')}
|
||||||
|
variant="outlined"
|
||||||
|
onChange={handleCustomArgsChange}
|
||||||
|
value={customArgs}
|
||||||
|
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||||
|
/>
|
||||||
|
</Grid> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
settings.fileRenaming ?
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<TextField
|
||||||
|
id="customFilenameInput"
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('customFilename')}
|
||||||
|
variant="outlined"
|
||||||
|
onChange={handleFilenameOverrideChange}
|
||||||
|
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||||
|
/>
|
||||||
|
</Grid> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
settings.pathOverriding ?
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>{i18n.t('customPath')}</InputLabel>
|
||||||
|
<Select
|
||||||
|
label={i18n.t('customPath')}
|
||||||
|
defaultValue={0}
|
||||||
|
variant={'outlined'}
|
||||||
|
value={downloadPath}
|
||||||
|
onChange={(e) => setDownloadPath(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{availableDownloadPaths.map((val: string, idx: number) => (
|
||||||
|
<MenuItem key={idx} value={idx}>{val}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} pt={2}>
|
||||||
|
<Grid item>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={url === ''}
|
||||||
|
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
|
||||||
|
>
|
||||||
|
{i18n.t('startButton')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid item>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => abort()}
|
||||||
|
>
|
||||||
|
{i18n.t('abortAllButton')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid >
|
||||||
|
{/* Format Selection grid */}
|
||||||
|
{
|
||||||
|
downloadFormats ? <Grid container spacing={2} mt={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="h6" component="div" pb={1}>
|
||||||
|
{downloadFormats.title}
|
||||||
|
</Typography>
|
||||||
|
{/* <Skeleton variant="rectangular" height={180} /> */}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} pb={1}>
|
||||||
|
<img src={downloadFormats.thumbnail} height={260} width="100%" style={{ objectFit: 'cover' }} />
|
||||||
|
</Grid>
|
||||||
|
{/* video only */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="body1" component="div">
|
||||||
|
Best quality
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item pr={2} py={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={pickedBestFormat !== ''}
|
||||||
|
onClick={() => {
|
||||||
|
setPickedBestFormat(downloadFormats.best.format_id)
|
||||||
|
setPickedVideoFormat('')
|
||||||
|
setPickedAudioFormat('')
|
||||||
|
}}>
|
||||||
|
{downloadFormats.best.format_note || downloadFormats.best.format_id} - {downloadFormats.best.vcodec}+{downloadFormats.best.acodec}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
{/* video only */}
|
||||||
|
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="body1" component="div">
|
||||||
|
Video data {downloadFormats.formats[1].acodec}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
{downloadFormats.formats
|
||||||
|
.filter(format => format.acodec === 'none' && format.vcodec !== 'none')
|
||||||
|
.map((format, idx) => (
|
||||||
|
<Grid item pr={2} py={1} key={idx}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
setPickedVideoFormat(format.format_id)
|
||||||
|
setPickedBestFormat('')
|
||||||
|
}}
|
||||||
|
disabled={pickedVideoFormat === format.format_id}
|
||||||
|
>
|
||||||
|
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="body1" component="div">
|
||||||
|
Audio data
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
{downloadFormats.formats
|
||||||
|
.filter(format => format.acodec !== 'none' && format.vcodec === 'none')
|
||||||
|
.map((format, idx) => (
|
||||||
|
<Grid item pr={2} py={1} key={idx}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
setPickedAudioFormat(format.format_id)
|
||||||
|
setPickedBestFormat('')
|
||||||
|
}}
|
||||||
|
disabled={pickedAudioFormat === format.format_id}
|
||||||
|
>
|
||||||
|
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<Grid item xs={12} pt={2}>
|
||||||
|
<ButtonGroup disableElevation variant="contained">
|
||||||
|
<Button
|
||||||
|
onClick={() => sendUrl()}
|
||||||
|
disabled={!pickedBestFormat && !(pickedAudioFormat || pickedVideoFormat)}
|
||||||
|
> Download
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setPickedAudioFormat('');
|
||||||
|
setPickedVideoFormat('');
|
||||||
|
setPickedBestFormat('');
|
||||||
|
}}
|
||||||
|
> Clear
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid> : null
|
||||||
|
}
|
||||||
|
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||||
|
{
|
||||||
|
activeDownloads.map(download => (
|
||||||
|
<Grid item xs={4} sm={8} md={6} key={download.id}>
|
||||||
|
<Fragment>
|
||||||
|
<StackableResult
|
||||||
|
title={download.info.title}
|
||||||
|
thumbnail={download.info.thumbnail}
|
||||||
|
percentage={download.progress.percentage}
|
||||||
|
stopCallback={() => abort(download.id)}
|
||||||
|
resolution={download.info.resolution ?? ''}
|
||||||
|
speed={download.progress.speed}
|
||||||
|
size={download.info.filesize_approx ?? 0}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
</Grid>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
<Snackbar
|
||||||
|
open={showToast === status.connected}
|
||||||
|
autoHideDuration={1500}
|
||||||
|
message="Connected"
|
||||||
|
onClose={() => setShowToast(false)}
|
||||||
|
/>
|
||||||
|
</Container >
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,13 @@ import {
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { debounceTime, distinctUntilChanged, map, of, takeWhile } from "rxjs";
|
import { debounceTime, distinctUntilChanged, map, of, takeWhile } from "rxjs";
|
||||||
import { Socket } from "socket.io-client";
|
import { CliArguments } from "./features/core/argsParser";
|
||||||
import { CliArguments } from "./classes";
|
import I18nBuilder from "./features/core/intl";
|
||||||
|
import { RPCClient } from "./features/core/rpcClient";
|
||||||
import {
|
import {
|
||||||
LanguageUnion,
|
LanguageUnion,
|
||||||
setCliArgs,
|
setCliArgs,
|
||||||
|
setEnableCustomArgs,
|
||||||
setFileRenaming,
|
setFileRenaming,
|
||||||
setFormatSelection,
|
setFormatSelection,
|
||||||
setLanguage,
|
setLanguage,
|
||||||
@@ -34,16 +36,11 @@ import {
|
|||||||
setTheme,
|
setTheme,
|
||||||
ThemeUnion
|
ThemeUnion
|
||||||
} from "./features/settings/settingsSlice";
|
} from "./features/settings/settingsSlice";
|
||||||
import { alreadyUpdated, updated } from "./features/status/statusSlice";
|
import { updated } from "./features/status/statusSlice";
|
||||||
import { I18nBuilder } from "./i18n";
|
|
||||||
import { RootState } from "./stores/store";
|
import { RootState } from "./stores/store";
|
||||||
import { validateDomain, validateIP } from "./utils";
|
import { validateDomain, validateIP } from "./utils";
|
||||||
|
|
||||||
type Props = {
|
export default function Settings({ socket }: { socket: WebSocket }) {
|
||||||
socket: Socket
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Settings({ socket }: Props) {
|
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const settings = useSelector((state: RootState) => state.settings)
|
||||||
const status = useSelector((state: RootState) => state.status)
|
const status = useSelector((state: RootState) => state.status)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@@ -51,6 +48,7 @@ export default function Settings({ socket }: Props) {
|
|||||||
const [invalidIP, setInvalidIP] = useState(false);
|
const [invalidIP, setInvalidIP] = useState(false);
|
||||||
|
|
||||||
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||||
|
const client = useMemo(() => new RPCClient(socket), [settings.serverAddr, settings.serverPort])
|
||||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
||||||
/**
|
/**
|
||||||
* Update the server ip address state and localstorage whenever the input value changes.
|
* Update the server ip address state and localstorage whenever the input value changes.
|
||||||
@@ -112,8 +110,7 @@ export default function Settings({ socket }: Props) {
|
|||||||
* Send via WebSocket a message in order to update the yt-dlp binary from server
|
* Send via WebSocket a message in order to update the yt-dlp binary from server
|
||||||
*/
|
*/
|
||||||
const updateBinary = () => {
|
const updateBinary = () => {
|
||||||
socket.emit('update-bin')
|
client.updateExecutable().then(() => dispatch(updated()))
|
||||||
dispatch(alreadyUpdated())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -259,6 +256,17 @@ export default function Settings({ socket }: Props) {
|
|||||||
}
|
}
|
||||||
label={i18n.t('filenameOverrideOption')}
|
label={i18n.t('filenameOverrideOption')}
|
||||||
/>
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.enableCustomArgs}
|
||||||
|
onChange={() => {
|
||||||
|
dispatch(setEnableCustomArgs(!settings.enableCustomArgs))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={i18n.t('customArgs')}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ languages:
|
|||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Enable output file name overriding
|
||||||
customFilename: Custom filemame (leave blank to use default)
|
customFilename: Custom filemame (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
italian:
|
italian:
|
||||||
urlInput: URL di YouTube o di qualsiasi altro servizio supportato
|
urlInput: URL di YouTube o di qualsiasi altro servizio supportato
|
||||||
statusTitle: Stato
|
statusTitle: Stato
|
||||||
@@ -50,6 +52,8 @@ languages:
|
|||||||
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
||||||
customFilename: Custom filemame (leave blank to use default)
|
customFilename: Custom filemame (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
chinese:
|
chinese:
|
||||||
urlInput: YouTube 或其他受支持服务的视频网址
|
urlInput: YouTube 或其他受支持服务的视频网址
|
||||||
statusTitle: 状态
|
statusTitle: 状态
|
||||||
@@ -75,6 +79,8 @@ languages:
|
|||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Enable output file name overriding
|
||||||
customFilename: Custom filemame (leave blank to use default)
|
customFilename: Custom filemame (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
spanish:
|
spanish:
|
||||||
urlInput: YouTube or other supported service video url
|
urlInput: YouTube or other supported service video url
|
||||||
statusTitle: Status
|
statusTitle: Status
|
||||||
@@ -100,6 +106,8 @@ languages:
|
|||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Enable output file name overriding
|
||||||
customFilename: Custom filemame (leave blank to use default)
|
customFilename: Custom filemame (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
russian:
|
russian:
|
||||||
urlInput: YouTube or other supported service video url
|
urlInput: YouTube or other supported service video url
|
||||||
statusTitle: Status
|
statusTitle: Status
|
||||||
@@ -125,6 +133,8 @@ languages:
|
|||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Enable output file name overriding
|
||||||
customFilename: Custom filemame (leave blank to use default)
|
customFilename: Custom filemame (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
korean:
|
korean:
|
||||||
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
||||||
statusTitle: 상태
|
statusTitle: 상태
|
||||||
@@ -150,6 +160,8 @@ languages:
|
|||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Enable output file name overriding
|
||||||
customFilename: Custom filemame (leave blank to use default)
|
customFilename: Custom filemame (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
japanese:
|
japanese:
|
||||||
urlInput: YouTubeまたはサポート済み動画のURL
|
urlInput: YouTubeまたはサポート済み動画のURL
|
||||||
statusTitle: 状態
|
statusTitle: 状態
|
||||||
@@ -174,4 +186,6 @@ languages:
|
|||||||
pathOverrideOption: Enable output path overriding
|
pathOverrideOption: Enable output path overriding
|
||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Enable output file name overriding
|
||||||
customFilename: Custom filemame (leave blank to use default)
|
customFilename: Custom filemame (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
@@ -1,65 +1,109 @@
|
|||||||
import { EightK, FourK, Hd, Sd } from "@mui/icons-material";
|
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 {
|
||||||
import { IMessage } from "../interfaces";
|
Button,
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Chip,
|
||||||
|
LinearProgress,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { ellipsis } from "../utils";
|
import { ellipsis } from "../utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
formattedLog: IMessage,
|
title: string,
|
||||||
title: string,
|
thumbnail: string,
|
||||||
thumbnail: string,
|
resolution: string
|
||||||
resolution: string
|
percentage: string,
|
||||||
progress: number,
|
size: number,
|
||||||
stopCallback: VoidFunction,
|
speed: number,
|
||||||
|
stopCallback: VoidFunction,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StackableResult({ formattedLog, title, thumbnail, resolution, progress, stopCallback }: Props) {
|
export function StackableResult({
|
||||||
const guessResolution = (xByY: string): any => {
|
title,
|
||||||
if (!xByY) return null;
|
thumbnail,
|
||||||
if (xByY.includes('4320')) return (<EightK color="primary" />);
|
resolution,
|
||||||
if (xByY.includes('2160')) return (<FourK color="primary" />);
|
percentage,
|
||||||
if (xByY.includes('1080')) return (<Hd color="primary" />);
|
speed,
|
||||||
if (xByY.includes('720')) return (<Sd color="primary" />);
|
size,
|
||||||
return null;
|
stopCallback
|
||||||
|
}: Props) {
|
||||||
|
const [isCompleted, setIsCompleted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (percentage === '-1') {
|
||||||
|
setIsCompleted(true)
|
||||||
}
|
}
|
||||||
|
}, [percentage])
|
||||||
|
|
||||||
const roundMB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)}MiB`
|
const guessResolution = (xByY: string): any => {
|
||||||
|
if (!xByY) return null;
|
||||||
|
if (xByY.includes('4320')) return (<EightK color="primary" />);
|
||||||
|
if (xByY.includes('2160')) return (<FourK color="primary" />);
|
||||||
|
if (xByY.includes('1080')) return (<Hd color="primary" />);
|
||||||
|
if (xByY.includes('720')) return (<Sd color="primary" />);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const percentageToNumber = () => isCompleted ? 100 : Number(percentage.replace('%', ''))
|
||||||
<Card>
|
|
||||||
<CardActionArea>
|
const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
|
||||||
{thumbnail !== '' ?
|
const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
|
||||||
<CardMedia
|
|
||||||
component="img"
|
return (
|
||||||
height={180}
|
<Card>
|
||||||
image={thumbnail}
|
<CardActionArea>
|
||||||
/> :
|
{thumbnail !== '' ?
|
||||||
<Skeleton variant="rectangular" height={180} />
|
<CardMedia
|
||||||
}
|
component="img"
|
||||||
<CardContent>
|
height={180}
|
||||||
{title !== '' ?
|
image={thumbnail}
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
/> :
|
||||||
{ellipsis(title, 54)}
|
<Skeleton variant="rectangular" height={180} />
|
||||||
</Typography> :
|
}
|
||||||
<Skeleton />
|
<CardContent>
|
||||||
}
|
{title !== '' ?
|
||||||
<Stack direction="row" spacing={1} py={2}>
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
<Chip label={formattedLog.status} color="primary" />
|
{ellipsis(title, 54)}
|
||||||
<Typography>{formattedLog.progress}</Typography>
|
</Typography> :
|
||||||
<Typography>{formattedLog.dlSpeed}</Typography>
|
<Skeleton />
|
||||||
<Typography>{roundMB(formattedLog.size ?? 0)}</Typography>
|
}
|
||||||
{guessResolution(resolution)}
|
<Stack direction="row" spacing={1} py={2}>
|
||||||
</Stack>
|
<Chip
|
||||||
{progress ?
|
label={isCompleted ? 'Completed' : 'Downloading'}
|
||||||
<LinearProgress variant="determinate" value={progress} /> :
|
color="primary"
|
||||||
null
|
size="small"
|
||||||
}
|
/>
|
||||||
</CardContent>
|
<Typography>{!isCompleted ? percentage : ''}</Typography>
|
||||||
</CardActionArea>
|
<Typography> {!isCompleted ? formatSpeedMiB(speed) : ''}</Typography>
|
||||||
<CardActions>
|
<Typography>{roundMiB(size ?? 0)}</Typography>
|
||||||
<Button variant="contained" size="small" color="primary" onClick={stopCallback}>
|
{guessResolution(resolution)}
|
||||||
Stop
|
</Stack>
|
||||||
</Button>
|
{percentage ?
|
||||||
</CardActions>
|
<LinearProgress
|
||||||
</Card>
|
variant="determinate"
|
||||||
)
|
value={percentageToNumber()}
|
||||||
|
color={isCompleted ? "secondary" : "primary"}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
<CardActions>
|
||||||
|
<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
|
// @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 language: string;
|
||||||
private textMap = i18n.languages;
|
private textMap = i18n.languages;
|
||||||
|
|
||||||
105
frontend/src/features/core/rpcClient.ts
Normal file
105
frontend/src/features/core/rpcClient.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { RPCRequest, RPCResponse, IDLMetadata } from "../../types"
|
||||||
|
|
||||||
|
import { getHttpRPCEndpoint } from '../../utils'
|
||||||
|
|
||||||
|
export class RPCClient {
|
||||||
|
private socket: WebSocket
|
||||||
|
private seq: number
|
||||||
|
|
||||||
|
constructor(socket: WebSocket) {
|
||||||
|
this.socket = socket
|
||||||
|
this.seq = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementSeq() {
|
||||||
|
return String(this.seq++)
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(req: RPCRequest) {
|
||||||
|
this.socket.send(JSON.stringify(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendHTTP<T>(req: RPCRequest) {
|
||||||
|
return new Promise<RPCResponse<T>>((resolve, reject) => {
|
||||||
|
fetch(getHttpRPCEndpoint(), {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req)
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => resolve(data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public download(url: string, args: string, pathOverride = '', renameTo = '') {
|
||||||
|
if (url) {
|
||||||
|
this.send({
|
||||||
|
id: this.incrementSeq(),
|
||||||
|
method: 'Service.Exec',
|
||||||
|
params: [{
|
||||||
|
URL: url.split("?list").at(0)!,
|
||||||
|
Params: args.split(" ").map(a => a.trim()),
|
||||||
|
Path: pathOverride,
|
||||||
|
Rename: renameTo,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public formats(url: string) {
|
||||||
|
if (url) {
|
||||||
|
return this.sendHTTP<IDLMetadata>({
|
||||||
|
id: this.incrementSeq(),
|
||||||
|
method: 'Service.Formats',
|
||||||
|
params: [{
|
||||||
|
URL: url.split("?list").at(0)!,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public running() {
|
||||||
|
this.send({
|
||||||
|
method: 'Service.Running',
|
||||||
|
params: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public kill(id: string) {
|
||||||
|
this.send({
|
||||||
|
method: 'Service.Kill',
|
||||||
|
params: [id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public killAll() {
|
||||||
|
this.send({
|
||||||
|
method: 'Service.KillAll',
|
||||||
|
params: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public freeSpace() {
|
||||||
|
return this.sendHTTP<number>({
|
||||||
|
method: 'Service.FreeSpace',
|
||||||
|
params: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public directoryTree() {
|
||||||
|
return this.sendHTTP<string[]>({
|
||||||
|
method: 'Service.DirectoryTree',
|
||||||
|
params: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateExecutable() {
|
||||||
|
return this.sendHTTP({
|
||||||
|
method: 'Service.UpdateExecutable',
|
||||||
|
params: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public decode(data: any): RPCResponse<any> {
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,16 @@ export type LanguageUnion = "english" | "chinese" | "russian" | "italian" | "spa
|
|||||||
export type ThemeUnion = "light" | "dark"
|
export type ThemeUnion = "light" | "dark"
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
serverAddr: string,
|
serverAddr: string
|
||||||
serverPort: string,
|
serverPort: string
|
||||||
language: LanguageUnion,
|
language: LanguageUnion
|
||||||
theme: ThemeUnion,
|
theme: ThemeUnion
|
||||||
cliArgs: string,
|
cliArgs: string
|
||||||
formatSelection: boolean,
|
formatSelection: boolean
|
||||||
ratelimit: string,
|
ratelimit: string
|
||||||
fileRenaming: boolean,
|
fileRenaming: boolean
|
||||||
pathOverriding: boolean,
|
pathOverriding: boolean
|
||||||
|
enableCustomArgs: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: SettingsState = {
|
const initialState: SettingsState = {
|
||||||
@@ -25,6 +26,7 @@ const initialState: SettingsState = {
|
|||||||
ratelimit: localStorage.getItem("rate-limit") ?? "",
|
ratelimit: localStorage.getItem("rate-limit") ?? "",
|
||||||
fileRenaming: localStorage.getItem("file-renaming") === "true",
|
fileRenaming: localStorage.getItem("file-renaming") === "true",
|
||||||
pathOverriding: localStorage.getItem("path-overriding") === "true",
|
pathOverriding: localStorage.getItem("path-overriding") === "true",
|
||||||
|
enableCustomArgs: localStorage.getItem("enable-custom-args") === "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsSlice = createSlice({
|
export const settingsSlice = createSlice({
|
||||||
@@ -67,6 +69,10 @@ export const settingsSlice = createSlice({
|
|||||||
state.fileRenaming = action.payload
|
state.fileRenaming = action.payload
|
||||||
localStorage.setItem("file-renaming", action.payload.toString())
|
localStorage.setItem("file-renaming", action.payload.toString())
|
||||||
},
|
},
|
||||||
|
setEnableCustomArgs: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.enableCustomArgs = action.payload
|
||||||
|
localStorage.setItem("enable-custom-args", action.payload.toString())
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,6 +86,7 @@ export const {
|
|||||||
setRateLimit,
|
setRateLimit,
|
||||||
setFileRenaming,
|
setFileRenaming,
|
||||||
setPathOverriding,
|
setPathOverriding,
|
||||||
|
setEnableCustomArgs,
|
||||||
} = settingsSlice.actions
|
} = settingsSlice.actions
|
||||||
|
|
||||||
export default settingsSlice.reducer
|
export default settingsSlice.reducer
|
||||||
@@ -1,30 +1,55 @@
|
|||||||
import { createSlice } from "@reduxjs/toolkit"
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
export interface StatusState {
|
export interface StatusState {
|
||||||
connected: boolean,
|
connected: boolean,
|
||||||
updated: boolean,
|
updated: boolean,
|
||||||
downloading: boolean,
|
downloading: boolean,
|
||||||
|
freeSpace: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: StatusState = {
|
const initialState: StatusState = {
|
||||||
connected: false,
|
connected: false,
|
||||||
updated: false,
|
updated: false,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
|
freeSpace: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const statusSlice = createSlice({
|
export const statusSlice = createSlice({
|
||||||
name: 'status',
|
name: 'status',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
connected: (state) => { state.connected = true },
|
connected: (state) => {
|
||||||
disconnected: (state) => { state.connected = false },
|
state.connected = true
|
||||||
updated: (state) => { state.updated = true },
|
},
|
||||||
alreadyUpdated: (state) => { state.updated = false },
|
disconnected: (state) => {
|
||||||
downloading: (state) => { state.downloading = true },
|
state.connected = false
|
||||||
finished: (state) => { state.downloading = false },
|
},
|
||||||
|
updated: (state) => {
|
||||||
|
state.updated = true
|
||||||
|
},
|
||||||
|
alreadyUpdated: (state) => {
|
||||||
|
state.updated = false
|
||||||
|
},
|
||||||
|
downloading: (state) => {
|
||||||
|
state.downloading = true
|
||||||
|
},
|
||||||
|
finished: (state) => {
|
||||||
|
state.downloading = false
|
||||||
|
},
|
||||||
|
setFreeSpace: (state, action: PayloadAction<number>) => {
|
||||||
|
state.freeSpace = action.payload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { connected, disconnected, updated, alreadyUpdated, downloading, finished } = statusSlice.actions
|
export const {
|
||||||
|
connected,
|
||||||
|
disconnected,
|
||||||
|
updated,
|
||||||
|
alreadyUpdated,
|
||||||
|
downloading,
|
||||||
|
finished,
|
||||||
|
setFreeSpace
|
||||||
|
} = statusSlice.actions
|
||||||
|
|
||||||
export default statusSlice.reducer
|
export default statusSlice.reducer
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { App } from './src/App'
|
import { App } from './App'
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root')!)
|
const root = ReactDOM.createRoot(document.getElementById('root')!)
|
||||||
root.render(
|
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,
|
|
||||||
}
|
|
||||||
62
frontend/src/types.d.ts
vendored
Normal file
62
frontend/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export type RPCMethods =
|
||||||
|
| "Service.Exec"
|
||||||
|
| "Service.Kill"
|
||||||
|
| "Service.Clear"
|
||||||
|
| "Service.Running"
|
||||||
|
| "Service.KillAll"
|
||||||
|
| "Service.FreeSpace"
|
||||||
|
| "Service.Formats"
|
||||||
|
| "Service.DirectoryTree"
|
||||||
|
| "Service.UpdateExecutable"
|
||||||
|
|
||||||
|
export type RPCRequest = {
|
||||||
|
method: RPCMethods,
|
||||||
|
params?: any[],
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RPCResponse<T> = {
|
||||||
|
result: T,
|
||||||
|
error: number | null
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RPCResult = {
|
||||||
|
id: string
|
||||||
|
progress: {
|
||||||
|
speed: number
|
||||||
|
eta: number
|
||||||
|
percentage: string
|
||||||
|
}
|
||||||
|
info: {
|
||||||
|
url: string
|
||||||
|
filesize_approx?: number
|
||||||
|
resolution?: string
|
||||||
|
thumbnail: string
|
||||||
|
title: string
|
||||||
|
vcodec?: string
|
||||||
|
acodec?: string
|
||||||
|
ext?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
* Validate an ip v4 via regex
|
||||||
* @param {string} ipAddr
|
* @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 {
|
export function toFormatArgs(codes: string[]): string {
|
||||||
if (codes.length > 1) {
|
if (codes.length > 1) {
|
||||||
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
||||||
@@ -106,5 +74,13 @@ export function toFormatArgs(codes: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWebSocketEndpoint() {
|
export function getWebSocketEndpoint() {
|
||||||
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}`
|
return `ws://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/ws-rpc`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHttpRPCEndpoint() {
|
||||||
|
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/http-rpc`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGiB(bytes: number) {
|
||||||
|
return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
||||||
}
|
}
|
||||||
@@ -9,10 +9,10 @@ export default defineConfig(() => {
|
|||||||
react(),
|
react(),
|
||||||
ViteYaml(),
|
ViteYaml(),
|
||||||
],
|
],
|
||||||
root: resolve(__dirname, 'frontend'),
|
root: resolve(__dirname, '.'),
|
||||||
build: {
|
build: {
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
outDir: resolve(__dirname, 'dist', 'frontend'),
|
outDir: resolve(__dirname, 'dist'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
27
go.mod
Normal file
27
go.mod
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
module github.com/marcopeocchi/yt-dlp-web-ui
|
||||||
|
|
||||||
|
go 1.19
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/goccy/go-json v0.10.0
|
||||||
|
github.com/gofiber/fiber/v2 v2.41.0
|
||||||
|
github.com/gofiber/websocket/v2 v2.1.2
|
||||||
|
github.com/google/uuid v1.3.0
|
||||||
|
github.com/marcopeocchi/fazzoletti v0.0.0-20221114144444-1e802380a7db
|
||||||
|
golang.org/x/sys v0.4.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||||
|
github.com/fasthttp/websocket v1.5.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.15.14 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.3 // indirect
|
||||||
|
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d // indirect
|
||||||
|
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
|
||||||
|
)
|
||||||
61
go.sum
Normal file
61
go.sum
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||||
|
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/fasthttp/websocket v1.5.0 h1:B4zbe3xXyvIdnqjOZrafVFklCUq5ZLo/TqCt5JA1wLE=
|
||||||
|
github.com/fasthttp/websocket v1.5.0/go.mod h1:n0BlOQvJdPbTuBkZT0O5+jk/sp/1/VCzquR1BehI2F4=
|
||||||
|
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
|
||||||
|
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/gofiber/fiber/v2 v2.40.1/go.mod h1:Gko04sLksnHbzLSRBFWPFdzM9Ws9pRxvvIaohJK1dsk=
|
||||||
|
github.com/gofiber/fiber/v2 v2.41.0 h1:YhNoUS/OTjEz+/WLYuQ01xI7RXgKEFnGBKMagAu5f0M=
|
||||||
|
github.com/gofiber/fiber/v2 v2.41.0/go.mod h1:RdebcCuCRFp4W6hr3968/XxwJVg0K+jr9/Ae0PFzZ0Q=
|
||||||
|
github.com/gofiber/websocket/v2 v2.1.2 h1:EulKyLB/fJgui5+6c8irwEnYQ9FRsrLZfkrq9OfTDGc=
|
||||||
|
github.com/gofiber/websocket/v2 v2.1.2/go.mod h1:S+sKWo0xeC7Wnz5h4/8f6D/NxsrLFIdWDYB3SyVO9pE=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.14.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||||
|
github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc=
|
||||||
|
github.com/klauspost/compress v1.15.14/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
|
||||||
|
github.com/marcopeocchi/fazzoletti v0.0.0-20221114144444-1e802380a7db h1:SmKRgCLsImPxBTIzmUpbQyv+7FembiZaq/QTwtDqar4=
|
||||||
|
github.com/marcopeocchi/fazzoletti v0.0.0-20221114144444-1e802380a7db/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
|
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
|
||||||
|
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
|
||||||
|
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d h1:Q+gqLBOPkFGHyCJxXMRqtUgUbTjI8/Ze8vu8GGyNFwo=
|
||||||
|
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.33.0/go.mod h1:KJRK/MXx0J+yd0c5hlR+s1tIHD72sniU8ZJjl97LIw4=
|
||||||
|
github.com/valyala/fasthttp v1.41.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
|
||||||
|
github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g=
|
||||||
|
github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||||
|
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
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=
|
||||||
56
main.go
Normal file
56
main.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContextKey interface{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
port int
|
||||||
|
downloadPath string
|
||||||
|
downloaderPath string
|
||||||
|
configFile string
|
||||||
|
|
||||||
|
//go:embed frontend/dist
|
||||||
|
frontend embed.FS
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
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, "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)
|
||||||
|
|
||||||
|
server.RunBlocking(ctx)
|
||||||
|
}
|
||||||
2202
pnpm-lock.yaml
generated
2202
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
23
server/cli/ascii.go
Normal file
23
server/cli/ascii.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FG
|
||||||
|
Red = "\033[31m"
|
||||||
|
Green = "\033[32m"
|
||||||
|
Yellow = "\033[33m"
|
||||||
|
Blue = "\033[34m"
|
||||||
|
Magenta = "\033[35m"
|
||||||
|
Cyan = "\033[36m"
|
||||||
|
Reset = "\033[0m"
|
||||||
|
// BG
|
||||||
|
BgRed = "\033[1;41m"
|
||||||
|
BgBlue = "\033[1;44m"
|
||||||
|
BgGreen = "\033[1;42m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Formats a message with the specified ascii escape code, then reset.
|
||||||
|
func Format(message string, code string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", code, message, Reset)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
37
server/internal/stack.go
Normal file
37
server/internal/stack.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
type Node[T any] struct {
|
||||||
|
Value T
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stack[T any] struct {
|
||||||
|
Nodes []*Node[T]
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stack[T]) Push(n *Node[T]) {
|
||||||
|
if s.count >= len(s.Nodes) {
|
||||||
|
Nodes := make([]*Node[T], len(s.Nodes)*2)
|
||||||
|
copy(Nodes, s.Nodes)
|
||||||
|
s.Nodes = Nodes
|
||||||
|
}
|
||||||
|
s.Nodes[s.count] = n
|
||||||
|
s.count++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stack[T]) Pop() *Node[T] {
|
||||||
|
if s.count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
node := s.Nodes[s.count-1]
|
||||||
|
s.count--
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stack[T]) IsEmpty() bool {
|
||||||
|
return s.count == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stack[T]) IsNotEmpty() bool {
|
||||||
|
return s.count != 0
|
||||||
|
}
|
||||||
121
server/memory_db.go
Normal file
121
server/memory_db.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In-Memory volatile Thread-Safe Key-Value Storage
|
||||||
|
type MemoryDB struct {
|
||||||
|
table map[string]*Process
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inits the db with an empty map of string->Process pointer
|
||||||
|
func (m *MemoryDB) New() {
|
||||||
|
m.table = make(map[string]*Process)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a process pointer given its id
|
||||||
|
func (m *MemoryDB) Get(id string) *Process {
|
||||||
|
m.mu.Lock()
|
||||||
|
res := m.table[id]
|
||||||
|
m.mu.Unlock()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store a pointer of a process and return its id
|
||||||
|
func (m *MemoryDB) Set(process *Process) string {
|
||||||
|
id := uuid.Must(uuid.NewRandom()).String()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.table[id] = process
|
||||||
|
m.mu.Unlock()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a process info/metadata, given the process id
|
||||||
|
func (m *MemoryDB) Update(id string, info DownloadInfo) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.table[id] != nil {
|
||||||
|
m.table[id].Info = info
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a process progress data, given the process id
|
||||||
|
// Used for updating completition percentage or ETA
|
||||||
|
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.table[id] != nil {
|
||||||
|
m.table[id].Progress = progress
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes a process progress, given the process id
|
||||||
|
func (m *MemoryDB) Delete(id string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.table, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a slice of all currently stored processes id
|
||||||
|
func (m *MemoryDB) Keys() []string {
|
||||||
|
m.mu.Lock()
|
||||||
|
keys := make([]string, len(m.table))
|
||||||
|
i := 0
|
||||||
|
for k := range m.table {
|
||||||
|
keys[i] = k
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a slice of all currently stored processes progess
|
||||||
|
func (m *MemoryDB) All() []ProcessResponse {
|
||||||
|
running := make([]ProcessResponse, len(m.table))
|
||||||
|
i := 0
|
||||||
|
for k, v := range m.table {
|
||||||
|
if v != nil {
|
||||||
|
running[i] = ProcessResponse{
|
||||||
|
Id: k,
|
||||||
|
Info: v.Info,
|
||||||
|
Progress: v.Progress,
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return running
|
||||||
|
}
|
||||||
|
|
||||||
|
// WIP: Persist the database in a single file named "session.dat"
|
||||||
|
func (m *MemoryDB) Persist() {
|
||||||
|
running := m.All()
|
||||||
|
|
||||||
|
session, err := json.Marshal(Session{
|
||||||
|
Processes: running,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println(cli.Red, "Failed to persist database", cli.Reset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile("session.dat", session, 0700)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(cli.Red, "Failed to persist database", cli.Reset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WIP: Restore a persisted state
|
||||||
|
func (m *MemoryDB) Restore() {
|
||||||
|
feed, _ := os.ReadFile("session.dat")
|
||||||
|
session := Session{}
|
||||||
|
json.Unmarshal(feed, &session)
|
||||||
|
}
|
||||||
205
server/process.go
Normal file
205
server/process.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/fazzoletti/slices"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
|
||||||
|
)
|
||||||
|
|
||||||
|
const template = `download:
|
||||||
|
{
|
||||||
|
"eta":%(progress.eta)s,
|
||||||
|
"percentage":"%(progress._percent_str)s",
|
||||||
|
"speed":%(progress.speed)s
|
||||||
|
}`
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg = config.Instance()
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProgressTemplate struct {
|
||||||
|
Percentage string `json:"percentage"`
|
||||||
|
Speed float32 `json:"speed"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Eta int `json:"eta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process descriptor
|
||||||
|
type Process struct {
|
||||||
|
id string
|
||||||
|
url string
|
||||||
|
params []string
|
||||||
|
Info DownloadInfo
|
||||||
|
Progress DownloadProgress
|
||||||
|
mem *MemoryDB
|
||||||
|
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(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 {
|
||||||
|
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
|
||||||
|
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",
|
||||||
|
"--no-colors",
|
||||||
|
"--no-playlist",
|
||||||
|
"--progress-template", strings.ReplaceAll(template, "\n", ""),
|
||||||
|
"-o",
|
||||||
|
fmt.Sprintf("%s/%s", out.path, out.filaneme),
|
||||||
|
}, p.params...)
|
||||||
|
|
||||||
|
// ----------------- main block ----------------- //
|
||||||
|
cmd := exec.Command(cfg.GetConfig().DownloaderPath, params...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
r, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Panicln(err)
|
||||||
|
}
|
||||||
|
scan := bufio.NewScanner(r)
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
log.Panicln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.id = p.mem.Set(p)
|
||||||
|
p.proc = cmd.Process
|
||||||
|
|
||||||
|
// ----------------- info block ----------------- //
|
||||||
|
// spawn a goroutine that retrieves the info for the download
|
||||||
|
go func() {
|
||||||
|
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.url, "-J")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Cannot retrieve info for", p.url)
|
||||||
|
}
|
||||||
|
info := DownloadInfo{URL: p.url}
|
||||||
|
json.Unmarshal(stdout, &info)
|
||||||
|
p.mem.Update(p.id, info)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// --------------- progress block --------------- //
|
||||||
|
// unbuffered channel connected to stdout
|
||||||
|
eventChan := make(chan string)
|
||||||
|
|
||||||
|
// spawn a goroutine that does the dirty job of parsing the stdout
|
||||||
|
// filling the channel with as many stdout line as yt-dlp produces (producer)
|
||||||
|
go func() {
|
||||||
|
defer r.Close()
|
||||||
|
defer p.Complete()
|
||||||
|
defer close(eventChan)
|
||||||
|
for scan.Scan() {
|
||||||
|
eventChan <- scan.Text()
|
||||||
|
}
|
||||||
|
cmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// do the unmarshal operation every 250ms (consumer)
|
||||||
|
go rx.Debounce(time.Millisecond*250, eventChan, func(text string) {
|
||||||
|
stdout := ProgressTemplate{}
|
||||||
|
err := json.Unmarshal([]byte(text), &stdout)
|
||||||
|
if err == nil {
|
||||||
|
p.mem.UpdateProgress(p.id, DownloadProgress{
|
||||||
|
Percentage: stdout.Percentage,
|
||||||
|
Speed: stdout.Speed,
|
||||||
|
ETA: stdout.Eta,
|
||||||
|
})
|
||||||
|
shortId := strings.Split(p.id, "-")[0]
|
||||||
|
log.Printf("[%s] %s %s\n", shortId, p.url, p.Progress.Percentage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// ------------- end progress block ------------- //
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep process in the memoryDB but marks it as complete
|
||||||
|
// Convention: All completed processes has progress -1
|
||||||
|
// and speed 0 bps.
|
||||||
|
func (p *Process) Complete() {
|
||||||
|
p.mem.UpdateProgress(p.id, DownloadProgress{
|
||||||
|
Percentage: "-1",
|
||||||
|
Speed: 0,
|
||||||
|
ETA: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill a process and remove it from the memory
|
||||||
|
func (p *Process) Kill() error {
|
||||||
|
p.mem.Delete(p.id)
|
||||||
|
// yt-dlp uses multiple child process the parent process
|
||||||
|
// has been spawned with setPgid = true. To properly kill
|
||||||
|
// all subprocesses a SIGTERM need to be sent to the correct
|
||||||
|
// process group
|
||||||
|
pgid, err := syscall.Getpgid(p.proc.Pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = syscall.Kill(-pgid, syscall.SIGTERM)
|
||||||
|
|
||||||
|
log.Println("Killed process", p.id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the available format for this URL
|
||||||
|
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
||||||
|
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.url, "-J")
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return DownloadFormats{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Wait()
|
||||||
|
|
||||||
|
info := DownloadFormats{URL: p.url}
|
||||||
|
best := Format{}
|
||||||
|
|
||||||
|
json.Unmarshal(stdout, &info)
|
||||||
|
json.Unmarshal(stdout, &best)
|
||||||
|
|
||||||
|
info.Best = best
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
39
server/rpc.go
Normal file
39
server/rpc.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/rpc/jsonrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wrapper for HTTP RPC request that implements io.Reader interface
|
||||||
|
type rpcRequest struct {
|
||||||
|
r io.Reader
|
||||||
|
rw io.ReadWriter
|
||||||
|
done chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRPCRequest(r io.Reader) *rpcRequest {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
done := make(chan bool)
|
||||||
|
return &rpcRequest{r, &buf, done}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rpcRequest) Read(p []byte) (n int, err error) {
|
||||||
|
return r.r.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rpcRequest) Write(p []byte) (n int, err error) {
|
||||||
|
return r.rw.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rpcRequest) Close() error {
|
||||||
|
r.done <- true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rpcRequest) Call() io.Reader {
|
||||||
|
go jsonrpc.ServeConn(r)
|
||||||
|
<-r.done
|
||||||
|
return r.rw
|
||||||
|
}
|
||||||
47
server/rx/extensions.go
Normal file
47
server/rx/extensions.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package rx
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
/*
|
||||||
|
Package rx contains:
|
||||||
|
- Definitions for common reactive programming functions/patterns
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ReactiveX inspired debounce function.
|
||||||
|
//
|
||||||
|
// Debounce emits a string from the source channel only after a particular
|
||||||
|
// time span determined a Go Interval
|
||||||
|
// --A--B--CD--EFG-------|>
|
||||||
|
//
|
||||||
|
// -t-> |>
|
||||||
|
// -t-> |> t is a timer tick
|
||||||
|
// -t-> |>
|
||||||
|
//
|
||||||
|
// --A-----C-----G-------|>
|
||||||
|
func Debounce(interval time.Duration, source chan string, cb func(emit string)) {
|
||||||
|
var item string
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case item = <-source:
|
||||||
|
timer.Reset(interval)
|
||||||
|
case <-timer.C:
|
||||||
|
if item != "" {
|
||||||
|
cb(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactiveX inspired sample function.
|
||||||
|
//
|
||||||
|
// Debounce emits the most recently emitted value from the source
|
||||||
|
// withing the timespan set by the span time.Duration
|
||||||
|
func Sample[T any](span time.Duration, source chan T, cb func(emit T)) {
|
||||||
|
timer := time.NewTimer(span)
|
||||||
|
for {
|
||||||
|
<-timer.C
|
||||||
|
cb(<-source)
|
||||||
|
timer.Reset(span)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
server/server.go
Normal file
67
server/server.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/rpc"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||||
|
"github.com/gofiber/websocket/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db MemoryDB
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunBlocking(ctx context.Context) {
|
||||||
|
fe := ctx.Value("frontend").(fs.SubFS)
|
||||||
|
port := ctx.Value("port").(int)
|
||||||
|
|
||||||
|
service := new(Service)
|
||||||
|
rpc.Register(service)
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
|
||||||
|
app.Use(cors.New())
|
||||||
|
app.Use("/", filesystem.New(filesystem.Config{
|
||||||
|
Root: http.FS(fe),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// RPC handlers
|
||||||
|
// websocket
|
||||||
|
app.Get("/ws-rpc", websocket.New(func(c *websocket.Conn) {
|
||||||
|
for {
|
||||||
|
mtype, reader, err := c.NextReader()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
res := NewRPCRequest(reader).Call()
|
||||||
|
|
||||||
|
writer, err := c.NextWriter(mtype)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
io.Copy(writer, res)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
// http-post
|
||||||
|
app.Post("/http-rpc", func(c *fiber.Ctx) error {
|
||||||
|
reader := c.Context().RequestBodyStream()
|
||||||
|
writer := c.Response().BodyWriter()
|
||||||
|
res := NewRPCRequest(reader).Call()
|
||||||
|
io.Copy(writer, res)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Server().StreamRequestBody = true
|
||||||
|
|
||||||
|
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
||||||
|
}
|
||||||
124
server/service.go
Normal file
124
server/service.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service int
|
||||||
|
|
||||||
|
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 DownloadSpecificArgs, result *string) error {
|
||||||
|
log.Println("Spawning new process for", args.URL)
|
||||||
|
p := Process{mem: &db, url: args.URL, params: args.Params}
|
||||||
|
p.Start(args.Path, args.Rename)
|
||||||
|
*result = p.id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progess retrieves the Progress of a specific Process given its Id
|
||||||
|
func (t *Service) Progess(args Args, progress *DownloadProgress) error {
|
||||||
|
*progress = db.Get(args.Id).Progress
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progess retrieves the Progress of a specific Process given its Id
|
||||||
|
func (t *Service) Formats(args Args, progress *DownloadFormats) error {
|
||||||
|
var err error
|
||||||
|
p := Process{url: args.URL}
|
||||||
|
*progress, err = p.GetFormatsSync()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending retrieves a slice of all Pending/Running processes ids
|
||||||
|
func (t *Service) Pending(args NoArgs, pending *Pending) error {
|
||||||
|
*pending = Pending(db.Keys())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running retrieves a slice of all Processes progress
|
||||||
|
func (t *Service) Running(args NoArgs, running *Running) error {
|
||||||
|
*running = db.All()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill kills a process given its id and remove it from the memoryDB
|
||||||
|
func (t *Service) Kill(args string, killed *string) error {
|
||||||
|
log.Println("Trying killing process with id", args)
|
||||||
|
proc := db.Get(args)
|
||||||
|
var err error
|
||||||
|
if proc != nil {
|
||||||
|
err = proc.Kill()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillAll kills all process unconditionally and removes them from
|
||||||
|
// the memory db
|
||||||
|
func (t *Service) KillAll(args NoArgs, killed *string) error {
|
||||||
|
log.Println("Killing all spawned processes", args)
|
||||||
|
keys := db.Keys()
|
||||||
|
var err error
|
||||||
|
for _, key := range keys {
|
||||||
|
proc := db.Get(key)
|
||||||
|
if proc != nil {
|
||||||
|
proc.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a process from the db rendering it unusable if active
|
||||||
|
func (t *Service) Clear(args string, killed *string) error {
|
||||||
|
log.Println("Clearing process with id", args)
|
||||||
|
db.Delete(args)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreeSpace gets the available from package sys util
|
||||||
|
func (t *Service) FreeSpace(args NoArgs, free *uint64) error {
|
||||||
|
freeSpace, err := sys.FreeSpace()
|
||||||
|
*free = freeSpace
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a flattned tree of the download directory
|
||||||
|
func (t *Service) DirectoryTree(args NoArgs, tree *[]string) error {
|
||||||
|
dfsTree, err := sys.DirectoryTree()
|
||||||
|
*tree = *dfsTree
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates the yt-dlp binary using its builtin function
|
||||||
|
func (t *Service) UpdateExecutable(args NoArgs, updated *bool) error {
|
||||||
|
log.Println("Updating yt-dlp executable to the latest release")
|
||||||
|
err := updater.UpdateExecutable()
|
||||||
|
if err != nil {
|
||||||
|
*updated = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*updated = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
15
server/src/interfaces/IDownloadMetadata.d.ts
vendored
15
server/src/interfaces/IDownloadMetadata.d.ts
vendored
@@ -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/src/interfaces/IPayload.d.ts
vendored
13
server/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/src/interfaces/IRecord.d.ts
vendored
14
server/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/src/interfaces/ISettings.d.ts
vendored
5
server/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/src/types/index.d.ts
vendored
6
server/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')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
62
server/sys/fs.go
Normal file
62
server/sys/fs.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package sys
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// package containing fs related operation (unix only)
|
||||||
|
|
||||||
|
// FreeSpace gets the available Bytes writable to download directory
|
||||||
|
func FreeSpace() (uint64, error) {
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
unix.Statfs(config.Instance().GetConfig().DownloadPath, &stat)
|
||||||
|
return (stat.Bavail * uint64(stat.Bsize)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
}
|
||||||
|
flattened := make([]string, 0)
|
||||||
|
|
||||||
|
root := Node{path: rootPath}
|
||||||
|
stack.Push(&internal.Node[Node]{
|
||||||
|
Value: root,
|
||||||
|
})
|
||||||
|
flattened = append(flattened, rootPath)
|
||||||
|
|
||||||
|
for stack.IsNotEmpty() {
|
||||||
|
current := stack.Pop().Value
|
||||||
|
children, err := os.ReadDir(current.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, entry := range children {
|
||||||
|
childPath := filepath.Join(current.path, entry.Name())
|
||||||
|
childNode := Node{path: childPath}
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
current.children = append(current.children, childNode)
|
||||||
|
stack.Push(&internal.Node[Node]{
|
||||||
|
Value: childNode,
|
||||||
|
})
|
||||||
|
flattened = append(flattened, childNode.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &flattened, nil
|
||||||
|
}
|
||||||
65
server/types.go
Normal file
65
server/types.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
// Progress for the Running call
|
||||||
|
type DownloadProgress struct {
|
||||||
|
Percentage string `json:"percentage"`
|
||||||
|
Speed float32 `json:"speed"`
|
||||||
|
ETA int `json:"eta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to deser the yt-dlp -J output
|
||||||
|
type DownloadInfo struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
Size int32 `json:"filesize_approx"`
|
||||||
|
VCodec string `json:"vcodec"`
|
||||||
|
ACodec string `json:"acodec"`
|
||||||
|
Extension string `json:"ext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to deser the formats in the -J output
|
||||||
|
type DownloadFormats struct {
|
||||||
|
Formats []Format `json:"formats"`
|
||||||
|
Best Format `json:"best"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A skimmed yt-dlp format node
|
||||||
|
type Format struct {
|
||||||
|
Format_id string `json:"format_id"`
|
||||||
|
Format_note string `json:"format_note"`
|
||||||
|
FPS float32 `json:"fps"`
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
VCodec string `json:"vcodec"`
|
||||||
|
ACodec string `json:"acodec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// struct representing the response sent to the client
|
||||||
|
// as JSON-RPC result field
|
||||||
|
type ProcessResponse struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Progress DownloadProgress `json:"progress"`
|
||||||
|
Info DownloadInfo `json:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// struct representing the current status of the memoryDB
|
||||||
|
// used for serializaton/persistence reasons
|
||||||
|
type Session struct {
|
||||||
|
Processes []ProcessResponse `json:"processes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// struct representing the intent to stop a specific process
|
||||||
|
type AbortRequest struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// struct representing the intent to start a download
|
||||||
|
type DownloadRequest struct {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
18
server/updater/update.go
Normal file
18
server/updater/update.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var path = config.Instance().GetConfig().DownloaderPath
|
||||||
|
|
||||||
|
// Update using the builtin function of yt-dlp
|
||||||
|
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/src/**/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user