diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5290988..7894f46 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx index d87e077..b0b3c68 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -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()); - const [messageMap, setMessageMap] = useState(new Map()); - const [downloadInfoMap, setDownloadInfoMap] = useState(new Map()); - const [downloadFormats, setDownloadFormats] = useState(); - const [pickedVideoFormat, setPickedVideoFormat] = useState(''); - const [pickedAudioFormat, setPickedAudioFormat] = useState(''); - const [pickedBestFormat, setPickedBestFormat] = useState(''); + // ephemeral state + const [progressMap, setProgressMap] = useState(new Map()); + const [messageMap, setMessageMap] = useState(new Map()); - const [downloadPath, setDownloadPath] = useState(0); - const [availableDownloadPaths, setAvailableDownloadPaths] = useState([]); + const [activeDownloads, setActiveDownloads] = useState(new Array()); + const [downloadInfoMap, setDownloadInfoMap] = useState(new Map()); + const [downloadFormats, setDownloadFormats] = useState(); + 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([]); - 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(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(data.pid, serverStates.PROG_DONE, messageMap, setMessageMap); - updateInStateMap(data.pid, 0, progressMap, setProgressMap); - socket.emit('disk-space') - dispatch(finished()) - return; - } - updateInStateMap(data.pid, data, messageMap, setMessageMap); - if (data.progress) { - updateInStateMap(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(); - 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) => { - 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(); + 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) => { + setUrl(e.target.value) + } + + /** + * Update the filename override state whenever the input value changes + * @param e Input change event + */ + const handleFilenameOverrideChange = (e: React.ChangeEvent) => { + 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) => { - 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 ( - - theme.zIndex.drawer + 1 }} - open={showBackdrop} - > - - - - - - - - - - ), - }} - /> - - - { - settings.fileRenaming ? - - - : - null - } - { - settings.pathOverriding ? - - - {i18n.t('customPath')} - - - : - null - } - - - - - - - - - - - - - {/* Format Selection grid */} - { - downloadFormats ? - - - - - - {downloadFormats.title} - - {/* */} - - - - - {/* video only */} - - - Best quality - - - - - - {/* video only */} - {downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ? - - - Video data {downloadFormats.formats[1].acodec} - - - : null - } - {downloadFormats.formats - .filter(format => format.acodec === 'none' && format.vcodec !== 'none') - .map((format, idx) => ( - - - - )) - } - {downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ? - - - Audio data - - - : null - } - {downloadFormats.formats - .filter(format => format.acodec !== 'none' && format.vcodec === 'none') - .map((format, idx) => ( - - - - )) - } - - - - - - - - - - : null - } - - { - Array - .from(messageMap) - .filter(flattened => [...flattened][0]) - .filter(flattened => [...flattened][1].toString() !== serverStates.PROG_DONE) - .flatMap(message => ( - - { - /* - Message[0] => key, the pid which is shared with the progress and download Maps - Message[1] => value, the actual formatted message sent from server - */ - } - - abort(message[0])} - resolution={ - settings.formatSelection - ? '' - : downloadInfoMap.get(message[0])?.best.resolution ?? '' - } - /> - - - )) - } + return ( + + theme.zIndex.drawer + 1 }} + open={showBackdrop} + > + + + + + + + + + + ), + }} + /> - setShowToast(false)} - /> - - ); + + { + settings.fileRenaming ? + + + : + null + } + { + settings.pathOverriding ? + + + {i18n.t('customPath')} + + + : + null + } + + + + + + + + + + + + + {/* Format Selection grid */} + { + downloadFormats ? + + + + + + {downloadFormats.title} + + {/* */} + + + + + {/* video only */} + + + Best quality + + + + + + {/* video only */} + {downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ? + + + Video data {downloadFormats.formats[1].acodec} + + + : null + } + {downloadFormats.formats + .filter(format => format.acodec === 'none' && format.vcodec !== 'none') + .map((format, idx) => ( + + + + )) + } + {downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ? + + + Audio data + + + : null + } + {downloadFormats.formats + .filter(format => format.acodec !== 'none' && format.vcodec === 'none') + .map((format, idx) => ( + + + + )) + } + + + + + + + + + + : null + } + + { + activeDownloads.map(download => ( + + + abort(download.id)} + resolution={download.info.resolution ?? ''} + speed={download.progress.speed} + size={download.info.filesize_approx ?? 0} + /> + + + )) + } + + setShowToast(false)} + /> + + ); } \ No newline at end of file diff --git a/frontend/src/Settings.tsx b/frontend/src/Settings.tsx index 7ea39fe..f26590b 100644 --- a/frontend/src/Settings.tsx +++ b/frontend/src/Settings.tsx @@ -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 ( diff --git a/frontend/src/components/StackableResult.tsx b/frontend/src/components/StackableResult.tsx index bcee75b..3dfa0c9 100644 --- a/frontend/src/components/StackableResult.tsx +++ b/frontend/src/components/StackableResult.tsx @@ -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 (); @@ -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 } - - {formattedLog.progress} - {formattedLog.dlSpeed} - {roundMB(formattedLog.size ?? 0)} + + {percentage} + {speed} + {roundMB(size ?? 0)} {guessResolution(resolution)} - {progress ? - : + {percentage ? + : null } diff --git a/frontend/src/rpcClient.ts b/frontend/src/rpcClient.ts new file mode 100644 index 0000000..94d5b83 --- /dev/null +++ b/frontend/src/rpcClient.ts @@ -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(req: RPCRequest) { + return new Promise>((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({ + 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 { + return JSON.parse(data) + } +} \ No newline at end of file diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts new file mode 100644 index 0000000..e8559ee --- /dev/null +++ b/frontend/src/types.d.ts @@ -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 = { + 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 +} \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 4206bf4..f0db68b 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -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` } \ No newline at end of file diff --git a/server/server.go b/server/server.go index 947eed7..2cb6b3a 100644 --- a/server/server.go +++ b/server/server.go @@ -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))) }