optimized and future-proofed stdout parser

This commit is contained in:
2023-01-04 11:53:36 +01:00
parent 210f323b4a
commit e1ab4c2d1a
8 changed files with 270 additions and 993 deletions

View File

@@ -1,21 +1,21 @@
import { import {
Button, Button,
Container, Container,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
FormGroup, FormGroup,
Grid, Grid,
InputAdornment, InputAdornment,
InputLabel, InputLabel,
MenuItem, MenuItem,
Paper, Paper,
Select, Select,
SelectChangeEvent, SelectChangeEvent,
Snackbar, Snackbar,
Stack, Stack,
Switch, Switch,
TextField, TextField,
Typography Typography
} from "@mui/material"; } from "@mui/material";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
@@ -23,14 +23,14 @@ import { debounceTime, distinctUntilChanged, map, of, takeWhile } from "rxjs";
import { Socket } from "socket.io-client"; import { Socket } from "socket.io-client";
import { CliArguments } from "./classes"; import { CliArguments } from "./classes";
import { import {
LanguageUnion, LanguageUnion,
setCliArgs, setCliArgs,
setFormatSelection, setFormatSelection,
setLanguage, setLanguage,
setServerAddr, setServerAddr,
setServerPort, setServerPort,
setTheme, setTheme,
ThemeUnion ThemeUnion
} from "./features/settings/settingsSlice"; } from "./features/settings/settingsSlice";
import { alreadyUpdated, updated } from "./features/status/statusSlice"; import { alreadyUpdated, updated } from "./features/status/statusSlice";
import { I18nBuilder } from "./i18n"; import { I18nBuilder } from "./i18n";
@@ -38,220 +38,220 @@ import { RootState } from "./stores/store";
import { validateDomain, validateIP } from "./utils"; import { validateDomain, validateIP } from "./utils";
type Props = { type Props = {
socket: Socket socket: Socket
} }
export default function Settings({ socket }: Props) { 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()
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 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.
* Validate the ip-addr then set.s * Validate the ip-addr then set.s
* @param event Input change event * @param event Input change event
*/ */
const handleAddrChange = (event: any) => { const handleAddrChange = (event: any) => {
const $serverAddr = of(event) const $serverAddr = of(event)
.pipe( .pipe(
map(event => event.target.value), map(event => event.target.value),
debounceTime(500), debounceTime(500),
distinctUntilChanged() distinctUntilChanged()
) )
.subscribe(addr => { .subscribe(addr => {
if (validateIP(addr)) { if (validateIP(addr)) {
setInvalidIP(false) setInvalidIP(false)
dispatch(setServerAddr(addr)) dispatch(setServerAddr(addr))
} else if (validateDomain(addr)) { } else if (validateDomain(addr)) {
setInvalidIP(false) setInvalidIP(false)
dispatch(setServerAddr(addr)) dispatch(setServerAddr(addr))
} else { } else {
setInvalidIP(true) setInvalidIP(true)
} }
}) })
return $serverAddr.unsubscribe() return $serverAddr.unsubscribe()
} }
/** /**
* Set server port * Set server port
*/ */
const handlePortChange = (event: any) => { const handlePortChange = (event: any) => {
const $port = of(event) const $port = of(event)
.pipe( .pipe(
map(event => event.target.value), map(event => event.target.value),
map(val => Number(val)), map(val => Number(val)),
takeWhile(val => isFinite(val) && val <= 65535), takeWhile(val => isFinite(val) && val <= 65535),
) )
.subscribe(port => { .subscribe(port => {
dispatch(setServerPort(port.toString())) dispatch(setServerPort(port.toString()))
}) })
return $port.unsubscribe() return $port.unsubscribe()
} }
/** /**
* Language toggler handler * Language toggler handler
*/ */
const handleLanguageChange = (event: SelectChangeEvent<LanguageUnion>) => { const handleLanguageChange = (event: SelectChangeEvent<LanguageUnion>) => {
dispatch(setLanguage(event.target.value as LanguageUnion)); dispatch(setLanguage(event.target.value as LanguageUnion));
} }
/** /**
* Theme toggler handler * Theme toggler handler
*/ */
const handleThemeChange = (event: SelectChangeEvent<ThemeUnion>) => { const handleThemeChange = (event: SelectChangeEvent<ThemeUnion>) => {
dispatch(setTheme(event.target.value as ThemeUnion)); dispatch(setTheme(event.target.value as ThemeUnion));
} }
/** /**
* 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') socket.emit('update-bin')
dispatch(alreadyUpdated()) dispatch(alreadyUpdated())
} }
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid item xs={12} md={12} lg={12}> <Grid item xs={12} md={12} lg={12}>
<Paper <Paper
sx={{ sx={{
p: 2, p: 2,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 240, minHeight: 240,
}} }}
> >
<Typography pb={2} variant="h6" color="primary"> <Typography pb={2} variant="h6" color="primary">
{i18n.t('settingsAnchor')} {i18n.t('settingsAnchor')}
</Typography> </Typography>
<FormGroup> <FormGroup>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={11}> <Grid item xs={12} md={11}>
<TextField <TextField
fullWidth fullWidth
label={i18n.t('serverAddressTitle')} label={i18n.t('serverAddressTitle')}
defaultValue={settings.serverAddr} defaultValue={settings.serverAddr}
error={invalidIP} error={invalidIP}
onChange={handleAddrChange} onChange={handleAddrChange}
InputProps={{ InputProps={{
startAdornment: <InputAdornment position="start">ws://</InputAdornment>, startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
}} }}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> />
</Grid>
<Grid item xs={12} md={1}>
<TextField
fullWidth
label={i18n.t('serverPortTitle')}
defaultValue={settings.serverPort}
onChange={handlePortChange}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
sx={{ mb: 2 }}
/>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">Language</InputLabel>
<Select
defaultValue={settings.language}
label="Language"
onChange={handleLanguageChange}
>
<MenuItem value="english">English</MenuItem>
<MenuItem value="spanish">Spanish</MenuItem>
<MenuItem value="italian">Italian</MenuItem>
<MenuItem value="chinese">Chinese</MenuItem>
<MenuItem value="russian">Russian</MenuItem>
<MenuItem value="korean">Korean</MenuItem>
<MenuItem value="japanese">Japanese</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Theme</InputLabel>
<Select
defaultValue={settings.theme}
label="Theme"
onChange={handleThemeChange}
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={'Max download speed' || i18n.t('serverPortTitle')}
defaultValue={settings.serverPort}
onChange={handlePortChange}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
sx={{ mb: 2 }}
/>
</Grid>
</Grid>
<FormControlLabel
control={
<Switch
defaultChecked={cliArgs.noMTime}
onChange={() => dispatch(setCliArgs(cliArgs.toggleNoMTime().toString()))}
/>
}
label={i18n.t('noMTimeCheckbox')}
sx={{ mt: 3 }}
/>
<FormControlLabel
control={
<Switch
defaultChecked={cliArgs.extractAudio}
onChange={() => dispatch(setCliArgs(cliArgs.toggleExtractAudio().toString()))}
disabled={settings.formatSelection}
/>
}
label={i18n.t('extractAudioCheckbox')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={settings.formatSelection}
onChange={() => {
dispatch(setCliArgs(cliArgs.disableExtractAudio().toString()))
dispatch(setFormatSelection(!settings.formatSelection))
}}
/>
}
label={i18n.t('formatSelectionEnabler')}
/>
<Grid>
<Stack direction="row">
<Button
sx={{ mr: 1, mt: 3 }}
variant="contained"
onClick={() => dispatch(updated())}
>
{i18n.t('updateBinButton')}
</Button>
{/* <Button sx={{ mr: 1, mt: 1 }} variant="outlined">Primary</Button> */}
</Stack>
</Grid>
</FormGroup>
</Paper>
</Grid> </Grid>
</Grid> <Grid item xs={12} md={1}>
<Snackbar <TextField
open={status.updated} fullWidth
autoHideDuration={1500} label={i18n.t('serverPortTitle')}
message={i18n.t('toastUpdated')} defaultValue={settings.serverPort}
onClose={updateBinary} onChange={handlePortChange}
/> error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
</Container> sx={{ mb: 2 }}
); />
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">Language</InputLabel>
<Select
defaultValue={settings.language}
label="Language"
onChange={handleLanguageChange}
>
<MenuItem value="english">English</MenuItem>
<MenuItem value="spanish">Spanish</MenuItem>
<MenuItem value="italian">Italian</MenuItem>
<MenuItem value="chinese">Chinese</MenuItem>
<MenuItem value="russian">Russian</MenuItem>
<MenuItem value="korean">Korean</MenuItem>
<MenuItem value="japanese">Japanese</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Theme</InputLabel>
<Select
defaultValue={settings.theme}
label="Theme"
onChange={handleThemeChange}
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
</FormControl>
</Grid>
{/* <Grid item xs={12} md={6}>
<TextField
fullWidth
label={'Max download speed' || i18n.t('serverPortTitle')}
defaultValue={settings.serverPort}
onChange={handlePortChange}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
sx={{ mb: 2 }}
/>
</Grid> */}
</Grid>
<FormControlLabel
control={
<Switch
defaultChecked={cliArgs.noMTime}
onChange={() => dispatch(setCliArgs(cliArgs.toggleNoMTime().toString()))}
/>
}
label={i18n.t('noMTimeCheckbox')}
sx={{ mt: 3 }}
/>
<FormControlLabel
control={
<Switch
defaultChecked={cliArgs.extractAudio}
onChange={() => dispatch(setCliArgs(cliArgs.toggleExtractAudio().toString()))}
disabled={settings.formatSelection}
/>
}
label={i18n.t('extractAudioCheckbox')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={settings.formatSelection}
onChange={() => {
dispatch(setCliArgs(cliArgs.disableExtractAudio().toString()))
dispatch(setFormatSelection(!settings.formatSelection))
}}
/>
}
label={i18n.t('formatSelectionEnabler')}
/>
<Grid>
<Stack direction="row">
<Button
sx={{ mr: 1, mt: 3 }}
variant="contained"
onClick={() => dispatch(updated())}
>
{i18n.t('updateBinButton')}
</Button>
{/* <Button sx={{ mr: 1, mt: 1 }} variant="outlined">Primary</Button> */}
</Stack>
</Grid>
</FormGroup>
</Paper>
</Grid>
</Grid>
<Snackbar
open={status.updated}
autoHideDuration={1500}
message={i18n.t('toastUpdated')}
onClose={updateBinary}
/>
</Container>
);
} }

View File

@@ -1,10 +1,12 @@
export class CliArguments { export class CliArguments {
private _extractAudio: boolean; private _extractAudio: boolean;
private _noMTime: boolean; private _noMTime: boolean;
private _proxy: string;
constructor(extractAudio = false, noMTime = false) { constructor(extractAudio = false, noMTime = false) {
this._extractAudio = extractAudio; this._extractAudio = extractAudio;
this._noMTime = noMTime; this._noMTime = noMTime;
this._proxy = ""
} }
public get extractAudio(): boolean { public get extractAudio(): boolean {

View File

@@ -23,6 +23,8 @@ export function StackableResult({ formattedLog, title, thumbnail, resolution, pr
return null; return null;
} }
const roundMB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)}MB`
return ( return (
<Card> <Card>
<CardActionArea> <CardActionArea>
@@ -45,7 +47,7 @@ export function StackableResult({ formattedLog, title, thumbnail, resolution, pr
<Chip label={formattedLog.status} color="primary" /> <Chip label={formattedLog.status} color="primary" />
<Typography>{formattedLog.progress}</Typography> <Typography>{formattedLog.progress}</Typography>
<Typography>{formattedLog.dlSpeed}</Typography> <Typography>{formattedLog.dlSpeed}</Typography>
<Typography>{formattedLog.size}</Typography> <Typography>{roundMB(formattedLog.size ?? 0)}</Typography>
{guessResolution(resolution)} {guessResolution(resolution)}
</Stack> </Stack>
{progress ? {progress ?

View File

@@ -1,7 +1,7 @@
export interface IMessage { export interface IMessage {
status: string, status: string,
progress?: string, progress?: string,
size?: string, size?: number,
dlSpeed?: string dlSpeed?: string
pid: number pid: number
} }

742
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,16 @@ class Process {
private metadata?: IDownloadMetadata; private metadata?: IDownloadMetadata;
private exePath = join(__dirname, 'yt-dlp'); private exePath = join(__dirname, 'yt-dlp');
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) { constructor(url: string, params: Array<string>, settings: any) {
this.url = url; this.url = url;
this.params = params || []; this.params = params || [];
@@ -48,7 +58,11 @@ class Process {
} }
const ytldp = spawn(this.exePath, const ytldp = spawn(this.exePath,
['-o', `${this.settings?.download_path || 'downloads/'}%(title)s.%(ext)s`] [
'-o', `${this.settings?.download_path || 'downloads/'}%(title)s.%(ext)s`,
'--progress-template', this.template,
'--no-colors',
]
.concat(sanitizedParams) .concat(sanitizedParams)
.concat((this.settings?.cliArgs ?? []).map(arg => arg.split(' ')).flat()) .concat((this.settings?.cliArgs ?? []).map(arg => arg.split(' ')).flat())
.concat([this.url]) .concat([this.url])

View File

@@ -5,6 +5,7 @@ import { Socket } from 'socket.io';
import MemoryDB from '../db/memoryDB'; import MemoryDB from '../db/memoryDB';
import { IPayload } from '../interfaces/IPayload'; import { IPayload } from '../interfaces/IPayload';
import { ISettings } from '../interfaces/ISettings'; import { ISettings } from '../interfaces/ISettings';
import { CLIProgress } from '../types';
import Logger from '../utils/BetterLogger'; import Logger from '../utils/BetterLogger';
import Process from './Process'; import Process from './Process';
import { states } from './states'; import { states } from './states';
@@ -118,7 +119,6 @@ function streamProcess(process: Process, socket: Socket) {
.subscribe({ .subscribe({
next: (stdout) => { next: (stdout) => {
socket.emit('progress', stdout) socket.emit('progress', stdout)
log.info(`proc-${stdout.pid}`, `${stdout.progress}\t${stdout.dlSpeed}`)
}, },
complete: () => { complete: () => {
process.kill().then(() => { process.kill().then(() => {
@@ -227,27 +227,20 @@ export function getQueueSize(): number {
* @returns * @returns
*/ */
const formatter = (stdout: string, pid: number) => { const formatter = (stdout: string, pid: number) => {
const cleanStdout = stdout try {
.replace(/\s\s+/g, ' ') const p: CLIProgress = JSON.parse(stdout);
.split(' '); if (p) {
const status = cleanStdout[0].replace(/\[|\]|\r/g, '');
switch (status) {
case 'download':
return { return {
status: states.PROC_DOWNLOAD, status: states.PROC_DOWNLOAD,
progress: cleanStdout[1], progress: p.percentage,
size: cleanStdout[3], size: p.size,
dlSpeed: cleanStdout[5], dlSpeed: p.speed,
pid: pid, pid: pid,
} }
case 'merge': }
return { } catch (e) {
status: states.PROC_MERGING, return {
progress: '100', progress: 0,
} }
default:
return {
progress: '0'
}
} }
} }

6
server/src/types/index.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
export type CLIProgress = {
percentage: string
speed: string
size: number
eta: number
}