download working

This commit is contained in:
2023-01-11 20:49:25 +01:00
parent b29cdf802d
commit 4d4582b3f7
8 changed files with 584 additions and 474 deletions

View File

@@ -39,7 +39,7 @@ function AppContent() {
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const socket = useMemo(() => io(getWebSocketEndpoint()), [])
const socket = useMemo(() => new WebSocket(getWebSocketEndpoint()), [])
const mode = settings.theme
@@ -60,9 +60,7 @@ function AppContent() {
/* Get disk free space */
useEffect(() => {
socket.on('free-space', (res: string) => {
setFreeDiskSpace(res)
})
}, [])
return (

View File

@@ -1,492 +1,455 @@
import { FileUpload } from "@mui/icons-material";
import {
Backdrop,
Button,
ButtonGroup,
CircularProgress,
Container,
FormControl,
Grid,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
Snackbar,
styled,
TextField,
Typography
Backdrop,
Button,
ButtonGroup,
CircularProgress,
Container,
FormControl,
Grid,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
Snackbar,
styled,
TextField,
Typography
} from "@mui/material";
import { Buffer } from 'buffer';
import { Fragment, useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Socket } from "socket.io-client";
import { CliArguments } from "./classes";
import { StackableResult } from "./components/StackableResult";
import { serverStates } from "./events";
import { connected, downloading, finished } from "./features/status/statusSlice";
import { connected } from "./features/status/statusSlice";
import { I18nBuilder } from "./i18n";
import { IDLMetadata, IDLMetadataAndPID, IMessage } from "./interfaces";
import { IDLMetadata, IMessage } from "./interfaces";
import { RPCClient } from "./rpcClient";
import { RootState } from "./stores/store";
import { RPCResult } from "./types";
import { isValidURL, toFormatArgs, updateInStateMap } from "./utils";
type Props = {
socket: Socket
socket: WebSocket
}
export default function Home({ socket }: Props) {
// redux state
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()
// redux state
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()
// ephemeral state
const [progressMap, setProgressMap] = useState(new Map<number, number>());
const [messageMap, setMessageMap] = useState(new Map<number, IMessage>());
const [downloadInfoMap, setDownloadInfoMap] = useState(new Map<number, IDLMetadata>());
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
const [pickedBestFormat, setPickedBestFormat] = useState('');
// ephemeral state
const [progressMap, setProgressMap] = useState(new Map<number, number>());
const [messageMap, setMessageMap] = useState(new Map<number, IMessage>());
const [downloadPath, setDownloadPath] = useState(0);
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([]);
const [activeDownloads, setActiveDownloads] = useState(new Array<RPCResult>());
const [downloadInfoMap, setDownloadInfoMap] = useState(new Map<number, IDLMetadata>());
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
const [pickedBestFormat, setPickedBestFormat] = useState('');
const [fileNameOverride, setFilenameOverride] = useState('');
const [downloadPath, setDownloadPath] = useState(0);
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([]);
const [url, setUrl] = useState('');
const [workingUrl, setWorkingUrl] = useState('');
const [showBackdrop, setShowBackdrop] = useState(false);
const [showToast, setShowToast] = useState(true);
const [fileNameOverride, setFilenameOverride] = useState('');
// memos
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
const [url, setUrl] = useState('');
const [workingUrl, setWorkingUrl] = useState('');
const [showBackdrop, setShowBackdrop] = useState(false);
const [showToast, setShowToast] = useState(true);
/* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/
useEffect(() => {
socket.on('connect', () => {
dispatch(connected())
socket.emit('fetch-jobs')
socket.emit('disk-space')
socket.emit('retrieve-jobs')
});
}, [])
// memos
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])
/* Ask server for pending jobs / background jobs */
useEffect(() => {
socket.on('pending-jobs', (count: number) => {
count === 0 ? setShowBackdrop(false) : setShowBackdrop(true)
})
}, [])
/* 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);
/* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/
useEffect(() => {
socket.onopen = () => {
dispatch(connected())
console.log('oke')
socket.send('fetch-jobs')
socket.send('disk-space')
socket.send('retrieve-jobs')
}
}, [])
/**
* Retrive url from input and display the formats selection view
*/
const sendUrlFormatSelection = () => {
socket.emit('send-url-format-selection', {
url: url,
})
useEffect(() => {
const interval = setInterval(() => client.running(), 1000)
return () => clearInterval(interval)
}, [])
setWorkingUrl(url)
setUrl('')
setPickedAudioFormat('')
setPickedVideoFormat('')
setPickedBestFormat('')
setTimeout(() => {
resetInput()
setShowBackdrop(true)
}, 250)
useEffect(() => {
socket.onmessage = (event) => {
const res = client.decode(event.data)
if (showBackdrop) {
setShowBackdrop(false)
}
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
}
}
}, [])
/**
* Update the url state whenever the input value changes
* @param e Input change event
*/
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value)
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);
client.download(
immediate || url || workingUrl,
cliArgs.toString() + toFormatArgs(codes),
)
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('')
setTimeout(() => {
resetInput()
setShowBackdrop(true)
}, 250)
}
/**
* 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)
}
/**
* 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()
}
/**
* 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)
const parseUrlListFile = (event: any) => {
const urlList = event.target.files
const reader = new FileReader()
reader.addEventListener('load', $event => {
const base64 = $event.target?.result!.toString().split(',')[1]
Buffer.from(base64!, 'base64')
.toString()
.trimEnd()
.split('\n')
.filter(_url => isValidURL(_url))
.forEach(_url => sendUrl(_url))
})
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 = '';
}
}
/**
* 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?: number) => {
if (id) {
updateInStateMap(id, null, downloadInfoMap, setDownloadInfoMap, true)
socket.emit('abort', { pid: id })
return
}
setDownloadFormats(undefined)
socket.emit('abort-all')
}
/* -------------------- styled components -------------------- */
const parseUrlListFile = (event: any) => {
const urlList = event.target.files
const reader = new FileReader()
reader.addEventListener('load', $event => {
const base64 = $event.target?.result!.toString().split(',')[1]
Buffer.from(base64!, 'base64')
.toString()
.trimEnd()
.split('\n')
.filter(_url => isValidURL(_url))
.forEach(_url => sendUrl(_url))
})
reader.readAsDataURL(urlList[0])
}
const Input = styled('input')({
display: 'none',
});
const resetInput = () => {
const input = document.getElementById('urlInput') as HTMLInputElement;
input.value = '';
const filename = document.getElementById('customFilenameInput') as HTMLInputElement;
if (filename) {
filename.value = '';
}
}
/* -------------------- styled components -------------------- */
const Input = styled('input')({
display: 'none',
});
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={showBackdrop}
>
<CircularProgress color="primary" />
</Backdrop>
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<TextField
fullWidth
id="urlInput"
label={i18n.t('urlInput')}
variant="outlined"
onChange={handleUrlChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<label htmlFor="icon-button-file">
<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>
))
}
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={showBackdrop}
>
<CircularProgress color="primary" />
</Backdrop>
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<TextField
fullWidth
id="urlInput"
label={i18n.t('urlInput')}
variant="outlined"
onChange={handleUrlChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<label htmlFor="icon-button-file">
<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>
<Snackbar
open={showToast === status.connected}
autoHideDuration={1500}
message="Connected"
onClose={() => setShowToast(false)}
/>
</Container >
);
<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}>
{
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 >
);
}

View File

@@ -40,7 +40,7 @@ import { RootState } from "./stores/store";
import { validateDomain, validateIP } from "./utils";
type Props = {
socket: Socket
socket: WebSocket
}
export default function Settings({ socket }: Props) {
@@ -112,8 +112,7 @@ export default function Settings({ socket }: Props) {
* Send via WebSocket a message in order to update the yt-dlp binary from server
*/
const updateBinary = () => {
socket.emit('update-bin')
dispatch(alreadyUpdated())
}
return (

View File

@@ -4,15 +4,24 @@ import { IMessage } from "../interfaces";
import { ellipsis } from "../utils";
type Props = {
formattedLog: IMessage,
title: string,
thumbnail: string,
resolution: string
progress: number,
percentage: string,
size: number,
speed: number,
stopCallback: VoidFunction,
}
export function StackableResult({ formattedLog, title, thumbnail, resolution, progress, stopCallback }: Props) {
export function StackableResult({
title,
thumbnail,
resolution,
percentage,
speed,
size,
stopCallback
}: Props) {
const guessResolution = (xByY: string): any => {
if (!xByY) return null;
if (xByY.includes('4320')) return (<EightK color="primary" />);
@@ -22,6 +31,8 @@ export function StackableResult({ formattedLog, title, thumbnail, resolution, pr
return null;
}
const percentageToNumber = () => Number(percentage.replace('%', ''))
const roundMB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)}MiB`
return (
@@ -43,14 +54,14 @@ export function StackableResult({ formattedLog, title, thumbnail, resolution, pr
<Skeleton />
}
<Stack direction="row" spacing={1} py={2}>
<Chip label={formattedLog.status} color="primary" />
<Typography>{formattedLog.progress}</Typography>
<Typography>{formattedLog.dlSpeed}</Typography>
<Typography>{roundMB(formattedLog.size ?? 0)}</Typography>
<Chip label={'Downloading'} color="primary" />
<Typography>{percentage}</Typography>
<Typography>{speed}</Typography>
<Typography>{roundMB(size ?? 0)}</Typography>
{guessResolution(resolution)}
</Stack>
{progress ?
<LinearProgress variant="determinate" value={progress} /> :
{percentage ?
<LinearProgress variant="determinate" value={percentageToNumber()} /> :
null
}
</CardContent>

81
frontend/src/rpcClient.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { RPCRequest, RPCResponse } from "./types"
import type { IDLMetadata } from './interfaces'
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('/rpc-http', {
method: 'POST',
body: JSON.stringify(req)
})
.then(res => res.json())
.then(data => resolve(data))
})
}
public download(url: string, args: string) {
if (url) {
this.send({
id: this.incrementSeq(),
method: 'Service.Exec',
params: [{
URL: url.split("?list").at(0)!,
Params: args.split(" ").map(a => a.trim()),
}]
})
}
}
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 decode(data: any): RPCResponse<any> {
return JSON.parse(data)
}
}

43
frontend/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
export type RPCMethods =
| "Service.Exec"
| "Service.Kill"
| "Service.Running"
| "Service.KillAll"
| "Service.FreeSpace"
| "Service.Formats"
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
}

View File

@@ -106,5 +106,9 @@ export function toFormatArgs(codes: string[]): string {
}
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`
}

View File

@@ -33,20 +33,31 @@ func RunBlocking(ctx context.Context) {
Root: http.FS(fe),
}))
app.Get("/ws", websocket.New(func(c *websocket.Conn) {
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
}
res := NewRPCRequest(reader).Call()
io.Copy(writer, res)
}
}))
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(":%s", port)))
}