Files
yt-dlp-webui/frontend/src/Home.tsx
2023-01-11 23:19:37 +01:00

460 lines
15 KiB
TypeScript

import { FileUpload } from "@mui/icons-material";
import {
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 { CliArguments } from "./classes";
import { StackableResult } from "./components/StackableResult";
import { serverStates } from "./events";
import { connected, setFreeSpace } from "./features/status/statusSlice";
import { I18nBuilder } from "./i18n";
import { IDLMetadata, IMessage } from "./interfaces";
import { RPCClient } from "./rpcClient";
import { RootState } from "./stores/store";
import { RPCResult } from "./types";
import { isValidURL, toFormatArgs, updateInStateMap } from "./utils";
type Props = {
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()
// ephemeral state
const [progressMap, setProgressMap] = useState(new Map<number, number>());
const [messageMap, setMessageMap] = useState(new Map<number, IMessage>());
const [activeDownloads, setActiveDownloads] = useState(new Array<RPCResult>());
const [downloadInfoMap, setDownloadInfoMap] = useState(new Map<number, IDLMetadata>());
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
const [pickedBestFormat, setPickedBestFormat] = useState('');
const [downloadPath, setDownloadPath] = useState(0);
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([]);
const [fileNameOverride, setFilenameOverride] = useState('');
const [url, setUrl] = useState('');
const [workingUrl, setWorkingUrl] = useState('');
const [showBackdrop, setShowBackdrop] = useState(false);
const [showToast, setShowToast] = useState(true);
// 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])
/* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/
useEffect(() => {
socket.onopen = () => {
dispatch(connected())
}
}, [])
useEffect(() => {
const interval = setInterval(() => client.running(), 1000)
return () => clearInterval(interval)
}, [])
useEffect(() => {
client.freeSpace()
.then(bytes => dispatch(setFreeSpace(bytes.result)))
}, [])
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
}
}
}, [])
useEffect(() => {
client.directoryTree()
.then(data => {
setAvailableDownloadPaths(data.result)
})
}, [])
/* -------------------- 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('')
setShowBackdrop(true)
client.formats(url)
?.then(formats => {
console.log(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)
}
/**
* 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 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 = '';
}
}
/* -------------------- 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}>
{
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 >
);
}