it just works

This commit is contained in:
2023-01-12 12:05:53 +01:00
parent 4c7faa1b46
commit 733e2ab006
54 changed files with 336 additions and 3608 deletions

View File

@@ -10,7 +10,7 @@
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

40
frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "yt-dlp-webui",
"version": "1.1.0",
"description": "A terrible webUI for yt-dlp, all-in-one solution.",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"author": "marcobaobao",
"license": "ISC",
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@koa/cors": "^3.3.0",
"@mui/icons-material": "^5.6.2",
"@mui/material": "^5.6.4",
"@reduxjs/toolkit": "^1.8.1",
"radash": "^10.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.1",
"react-router-dom": "^6.3.0",
"rxjs": "^7.4.0",
"uuid": "^8.3.2"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.2",
"@types/node": "^18.11.18",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/react-router-dom": "^5.3.3",
"@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "^1.3.2",
"buffer": "^6.0.3",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"typescript": "^4.6.4",
"vite": "^2.9.10"
}
}

View File

@@ -17,13 +17,12 @@ import {
} from "@mui/material";
import { grey } from "@mui/material/colors";
import ListItemButton from '@mui/material/ListItemButton';
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Provider, useSelector } from "react-redux";
import {
BrowserRouter as Router, Link, Route,
Routes
} from 'react-router-dom';
import ArchivedDownloads from "./Archived";
import { AppBar } from "./components/AppBar";
import { Drawer } from "./components/Drawer";
import Home from "./Home";
@@ -163,9 +162,8 @@ function AppContent() {
>
<Toolbar />
<Routes>
<Route path="/" element={<Home socket={socket}></Home>}></Route>
<Route path="/settings" element={<Settings socket={socket}></Settings>}></Route>
<Route path="/downloaded" element={<ArchivedDownloads></ArchivedDownloads>}></Route>
<Route path="/" element={<Home socket={socket} />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Box>
</Box>

View File

@@ -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>
);
}

View File

@@ -21,16 +21,14 @@ import {
import { Buffer } from 'buffer';
import { Fragment, useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { CliArguments } from "./classes";
import { StackableResult } from "./components/StackableResult";
import { serverStates } from "./events";
import { CliArguments } from "./features/core/argsParser";
import I18nBuilder from "./features/core/intl";
import { RPCClient } from "./features/core/rpcClient";
import { connected, setFreeSpace } from "./features/status/statusSlice";
import { I18nBuilder } from "./i18n";
import { IDLMetadata, IMessage } from "./interfaces";
import { RPCClient } from "./rpcClient";
import { RootState } from "./stores/store";
import { RPCResult } from "./types";
import { isValidURL, toFormatArgs, updateInStateMap } from "./utils";
import { IDLMetadata, RPCResult } from "./types";
import { isValidURL, toFormatArgs } from "./utils";
type Props = {
socket: WebSocket
@@ -43,11 +41,7 @@ export default function Home({ socket }: Props) {
const dispatch = useDispatch()
// ephemeral state
const [progressMap, setProgressMap] = useState(new Map<number, number>());
const [messageMap, setMessageMap] = useState(new Map<number, IMessage>());
const [activeDownloads, setActiveDownloads] = useState(new Array<RPCResult>());
const [downloadInfoMap, setDownloadInfoMap] = useState(new Map<number, IDLMetadata>());
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
@@ -60,6 +54,7 @@ export default function Home({ socket }: Props) {
const [url, setUrl] = useState('');
const [workingUrl, setWorkingUrl] = useState('');
const [showBackdrop, setShowBackdrop] = useState(false);
const [showToast, setShowToast] = useState(true);
@@ -89,9 +84,9 @@ export default function Home({ socket }: Props) {
useEffect(() => {
socket.onmessage = (event) => {
const res = client.decode(event.data)
if (showBackdrop) {
setShowBackdrop(false)
}
setShowBackdrop(false)
switch (typeof res.result) {
case 'object':
setActiveDownloads(
@@ -127,6 +122,8 @@ export default function Home({ socket }: Props) {
client.download(
immediate || url || workingUrl,
cliArgs.toString() + toFormatArgs(codes),
availableDownloadPaths[downloadPath] ?? '',
fileNameOverride
)
setUrl('')
@@ -154,7 +151,6 @@ export default function Home({ socket }: Props) {
client.formats(url)
?.then(formats => {
console.log(formats)
setDownloadFormats(formats.result)
setShowBackdrop(false)
resetInput()

View File

@@ -20,8 +20,8 @@ import {
import { useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { debounceTime, distinctUntilChanged, map, of, takeWhile } from "rxjs";
import { Socket } from "socket.io-client";
import { CliArguments } from "./classes";
import { CliArguments } from "./features/core/argsParser";
import I18nBuilder from "./features/core/intl";
import {
LanguageUnion,
setCliArgs,
@@ -34,16 +34,11 @@ import {
setTheme,
ThemeUnion
} from "./features/settings/settingsSlice";
import { alreadyUpdated, updated } from "./features/status/statusSlice";
import { I18nBuilder } from "./i18n";
import { updated } from "./features/status/statusSlice";
import { RootState } from "./stores/store";
import { validateDomain, validateIP } from "./utils";
type Props = {
socket: WebSocket
}
export default function Settings({ socket }: Props) {
export default function Settings() {
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()

View File

@@ -1,6 +1,18 @@
import { EightK, FourK, Hd, Sd } from "@mui/icons-material";
import { Button, Card, CardActionArea, CardActions, CardContent, CardMedia, Chip, LinearProgress, Skeleton, Stack, Typography } from "@mui/material";
import { IMessage } from "../interfaces";
import {
Button,
Card,
CardActionArea,
CardActions,
CardContent,
CardMedia,
Chip,
LinearProgress,
Skeleton,
Stack,
Typography
} from "@mui/material";
import { useEffect, useState } from "react";
import { ellipsis } from "../utils";
type Props = {
@@ -22,6 +34,14 @@ export function StackableResult({
size,
stopCallback
}: Props) {
const [isCompleted, setIsCompleted] = useState(false)
useEffect(() => {
if (percentage === '-1') {
setIsCompleted(true)
}
}, [percentage])
const guessResolution = (xByY: string): any => {
if (!xByY) return null;
if (xByY.includes('4320')) return (<EightK color="primary" />);
@@ -31,9 +51,10 @@ export function StackableResult({
return null;
}
const percentageToNumber = () => Number(percentage.replace('%', ''))
const percentageToNumber = () => isCompleted ? 100 : Number(percentage.replace('%', ''))
const roundMB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)}MiB`
const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
return (
<Card>
@@ -54,21 +75,33 @@ export function StackableResult({
<Skeleton />
}
<Stack direction="row" spacing={1} py={2}>
<Chip label={'Downloading'} color="primary" />
<Typography>{percentage}</Typography>
<Typography>{speed}</Typography>
<Typography>{roundMB(size ?? 0)}</Typography>
<Chip
label={isCompleted ? 'Completed' : 'Downloading'}
color="primary"
size="small"
/>
<Typography>{!isCompleted ? percentage : ''}</Typography>
<Typography> {!isCompleted ? formatSpeedMiB(speed) : ''}</Typography>
<Typography>{roundMiB(size ?? 0)}</Typography>
{guessResolution(resolution)}
</Stack>
{percentage ?
<LinearProgress variant="determinate" value={percentageToNumber()} /> :
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted ? "secondary" : "primary"}
/> :
null
}
</CardContent>
</CardActionArea>
<CardActions>
<Button variant="contained" size="small" color="primary" onClick={stopCallback}>
Stop
<Button
variant="contained"
size="small"
color="primary"
onClick={stopCallback}>
{isCompleted ? "Clear" : "Stop"}
</Button>
</CardActions>
</Card>

View File

@@ -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>
)
}

View File

@@ -1,7 +1,7 @@
// @ts-nocheck
import i18n from "./assets/i18n.yaml";
import i18n from "../../assets/i18n.yaml";
export class I18nBuilder {
export default class I18nBuilder {
private language: string;
private textMap = i18n.languages;

View File

@@ -1,7 +1,6 @@
import type { RPCRequest, RPCResponse } from "./types"
import type { IDLMetadata } from './interfaces'
import type { RPCRequest, RPCResponse, IDLMetadata } from "../../types"
import { getHttpRPCEndpoint } from './utils'
import { getHttpRPCEndpoint } from '../../utils'
export class RPCClient {
private socket: WebSocket
@@ -31,7 +30,7 @@ export class RPCClient {
})
}
public download(url: string, args: string) {
public download(url: string, args: string, pathOverride = '', renameTo = '') {
if (url) {
this.send({
id: this.incrementSeq(),
@@ -39,6 +38,8 @@ export class RPCClient {
params: [{
URL: url.split("?list").at(0)!,
Params: args.split(" ").map(a => a.trim()),
Path: pathOverride,
Rename: renameTo,
}]
})
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './src/App'
import { App } from './App'
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(

View File

@@ -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,
}

View File

@@ -6,6 +6,7 @@ export type RPCMethods =
| "Service.FreeSpace"
| "Service.Formats"
| "Service.DirectoryTree"
| "Service.UpdateExecutable"
export type RPCRequest = {
method: RPCMethods,
@@ -41,4 +42,20 @@ export type RPCResult = {
export type RPCParams = {
URL: string
Params?: string
}
export interface IDLMetadata {
formats: Array<IDLFormat>,
best: IDLFormat,
thumbnail: string,
title: string,
}
export interface IDLFormat {
format_id: string,
format_note: string,
fps: number,
resolution: string,
vcodec: string,
acodec: string,
}

View File

@@ -1,5 +1,3 @@
import { IMessage } from "./interfaces"
/**
* Validate an ip v4 via regex
* @param {string} ipAddr
@@ -65,36 +63,6 @@ export function detectSpeed(str: string): number {
}
}
/**
* Update a map stored in React State, in this specific impl. all maps have integer keys
* @param k Map key
* @param v Map value
* @param target The target map saved in-state
* @param callback calls React's StateAction function with the newly created Map
* @param remove -optional- is it an update or a deletion operation?
*/
export function updateInStateMap<K, V>(k: K, v: any, target: Map<K, V>, callback: Function, remove: boolean = false) {
if (remove) {
const _target = target
_target.delete(k)
callback(new Map(_target))
return;
}
callback(new Map(target.set(k, v)));
}
export function updateInStateArray<T>(v: T, target: Array<T>, callback: Function) { }
/**
* Pre like function
* @param data
* @returns formatted server message
*/
export function buildMessage(data: IMessage) {
return `operation: ${data.status || '...'} \nprogress: ${data.progress || '?'} \nsize: ${data.size || '?'} \nspeed: ${data.dlSpeed || '?'}`;
}
export function toFormatArgs(codes: string[]): string {
if (codes.length > 1) {
return codes.reduce((v, a) => ` -f ${v}+${a}`)

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import react from "@vitejs/plugin-react";
import ViteYaml from '@modyfi/vite-plugin-yaml';
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig(() => {
return {
plugins: [
react(),
ViteYaml(),
],
root: resolve(__dirname, '.'),
build: {
emptyOutDir: true,
outDir: resolve(__dirname, 'dist'),
}
}
})