From 908f4c66366c936a836cc10e940ed6bdae20b198 Mon Sep 17 00:00:00 2001 From: marcobaobao Date: Wed, 24 May 2023 13:19:04 +0200 Subject: [PATCH] first implementation of downloaded files viewer --- frontend/src/App.tsx | 26 +++-- frontend/src/Downloaded.tsx | 164 +++++++++++++++++++++++++++++++ frontend/src/hooks/observable.ts | 44 +++++++++ frontend/src/types/index.d.ts | 43 +++++--- frontend/src/utils.ts | 4 + server/rest/handlers.go | 24 ++++- server/server.go | 2 +- 7 files changed, 276 insertions(+), 31 deletions(-) create mode 100644 frontend/src/Downloaded.tsx create mode 100644 frontend/src/hooks/observable.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 81e58fe..181acb8 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,21 +19,22 @@ import ListItemIcon from '@mui/material/ListItemIcon' import ListItemText from '@mui/material/ListItemText' import Toolbar from '@mui/material/Toolbar' import Typography from '@mui/material/Typography' - import ListItemButton from '@mui/material/ListItemButton' + import { grey } from '@mui/material/colors' + import { Suspense, lazy, useMemo, useState } from 'react' import { Provider, useDispatch, useSelector } from 'react-redux' -import { - Link, Route, - BrowserRouter as Router, - Routes -} from 'react-router-dom' -import AppBar from './components/AppBar' -import Drawer from './components/Drawer' + +import { Link, Route, BrowserRouter, Routes } from 'react-router-dom' import { toggleListView } from './features/settings/settingsSlice' import { RootState, store } from './stores/store' + +import AppBar from './components/AppBar' +import Drawer from './components/Drawer' + import { formatGiB } from './utils' +import Downloaded from './Downloaded' function AppContent() { const [open, setOpen] = useState(false) @@ -63,7 +64,7 @@ function AppContent() { return ( - + @@ -180,10 +181,15 @@ function AppContent() { } /> + }> + + + } /> - + ) } diff --git a/frontend/src/Downloaded.tsx b/frontend/src/Downloaded.tsx new file mode 100644 index 0000000..84d8819 --- /dev/null +++ b/frontend/src/Downloaded.tsx @@ -0,0 +1,164 @@ +import { + Backdrop, + Button, + Checkbox, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Paper, + SpeedDial, + SpeedDialAction, + SpeedDialIcon +} from '@mui/material' + +import VideoFileIcon from '@mui/icons-material/VideoFile' +import { Buffer } from 'buffer' +import { useEffect, useMemo, useState } from 'react' +import { useSelector } from 'react-redux' +import { BehaviorSubject, combineLatestWith, map, share } from 'rxjs' +import { useObservable } from './hooks/observable' +import { RootState } from './stores/store' +import { DeleteRequest, DirectoryEntry } from './types' +import DeleteForeverIcon from '@mui/icons-material/DeleteForever' + +export default function Downloaded() { + const settings = useSelector((state: RootState) => state.settings) + + const [openDialog, setOpenDialog] = useState(false) + + const serverAddr = + `${window.location.protocol}//${settings.serverAddr}:${settings.serverPort}` + + const files$ = useMemo(() => new BehaviorSubject([]), []) + const selected$ = useMemo(() => new BehaviorSubject([]), []) + + const fetcher = () => fetch(`${serverAddr}/downloaded`) + .then(res => res.json()) + .then(data => files$.next(data)) + + const selectable$ = useMemo(() => files$.pipe( + combineLatestWith(selected$), + map(([data, selected]) => data.map(x => ({ + ...x, + selected: selected.includes(x.name) + }))), + share() + ), []) + + const selectable = useObservable(selectable$, []) + + const addSelected = (name: string) => { + selected$.value.includes(name) + ? selected$.next(selected$.value.filter(val => val !== name)) + : selected$.next([...selected$.value, name]) + } + + const deleteSelected = () => { + Promise.all(selectable + .filter(entry => entry.selected) + .map(entry => fetch(`${serverAddr}/delete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + path: entry.path, + shaSum: entry.shaSum, + } as DeleteRequest) + })) + ).then(fetcher) + } + + useEffect(() => { + fetcher() + }, [settings.serverAddr, settings.serverPort]) + + return ( + + theme.zIndex.drawer + 1 }} + open={selectable.length === 0} + > + + + + + {selectable.map((file) => ( + addSelected(file.name)} + /> + } + disablePadding + > + + + + + window.open( + `${serverAddr}/play?path=${Buffer.from(file.path).toString('hex')}` + )} /> + + + ))} + + + } + > + } + tooltipTitle={`Delete selected`} + tooltipOpen + onClick={() => setOpenDialog(true)} + /> + + setOpenDialog(false)} + > + + Are you sure? + + + + You're deleting: + +
    + {selected$.value.map((entry, idx) => ( +
  • {entry}
  • + ))} +
+
+ + + + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/hooks/observable.ts b/frontend/src/hooks/observable.ts new file mode 100644 index 0000000..174f202 --- /dev/null +++ b/frontend/src/hooks/observable.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react' +import { Observable } from 'rxjs' + +/** + * Handles the subscription and unsubscription from an observable. + * Automatically disposes the subscription. + * @param source$ source observable + * @param nextHandler subscriber function + * @param errHandler error catching callback + */ +export function useSubscription( + source$: Observable, + nextHandler: (value: T) => void, + errHandler?: (err: any) => void, +) { + useEffect(() => { + if (source$) { + const sub = source$.subscribe({ + next: nextHandler, + error: errHandler, + }) + return () => sub.unsubscribe() + } + }, [source$]) +} + +/** + * Use an observable as state + * @param source$ source observable + * @param initialState the initial state prior to the emission + * @param errHandler error catching callback + * @returns value emitted to the observable + */ +export function useObservable( + source$: Observable, + initialState: T, + errHandler?: (err: any) => void, +): T { + const [value, setValue] = useState(initialState) + + useSubscription(source$, setValue, errHandler) + + return value +} \ No newline at end of file diff --git a/frontend/src/types/index.d.ts b/frontend/src/types/index.d.ts index f807ad6..041012a 100644 --- a/frontend/src/types/index.d.ts +++ b/frontend/src/types/index.d.ts @@ -10,13 +10,13 @@ export type RPCMethods = | "Service.UpdateExecutable" export type RPCRequest = { - method: RPCMethods, - params?: any[], + method: RPCMethods + params?: any[] id?: string } export type RPCResponse = { - result: T, + result: T error: number | null id?: string } @@ -46,18 +46,29 @@ export type RPCParams = { } export interface DLMetadata { - formats: Array, - best: DLFormat, - thumbnail: string, - title: string, + formats: Array + best: DLFormat + thumbnail: string + title: string } -export interface DLFormat { - format_id: string, - format_note: string, - fps: number, - resolution: string, - vcodec: string, - acodec: string, - filesize_approx: number, -} \ No newline at end of file +export type DLFormat = { + format_id: string + format_note: string + fps: number + resolution: string + vcodec: string + acodec: string + filesize_approx: number +} + +export type DirectoryEntry = { + name: string + path: string + shaSum: string +} + +export type DeleteRequest = Omit + +export type PlayRequest = Omit + diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index e85a106..b9d7ba3 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -82,6 +82,10 @@ export function getHttpRPCEndpoint() { return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/http-rpc` } +export function getHttpEndpoint() { + return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}` +} + export function formatGiB(bytes: number) { return `${(bytes / 1_000_000_000).toFixed(0)}GiB` } diff --git a/server/rest/handlers.go b/server/rest/handlers.go index 5506382..5e98ba4 100644 --- a/server/rest/handlers.go +++ b/server/rest/handlers.go @@ -3,7 +3,9 @@ package rest import ( "crypto/sha256" "encoding/hex" + "errors" "io/fs" + "net/http" "os" "path/filepath" "strings" @@ -50,6 +52,7 @@ func ListDownloaded(ctx *fiber.Ctx) error { return err } + ctx.Status(http.StatusOK) return ctx.JSON(files) } @@ -74,6 +77,10 @@ func DeleteFile(ctx *fiber.Ctx) error { return e.Path == req.Path && e.SHASum == req.SHASum }) + if index == -1 { + ctx.SendString("shasum doesn't match") + } + if index >= 0 { err := os.Remove(req.Path) if err != nil { @@ -81,6 +88,7 @@ func DeleteFile(ctx *fiber.Ctx) error { } } + ctx.Status(fiber.StatusOK) return ctx.JSON(index) } @@ -89,18 +97,26 @@ type PlayRequest struct { } func PlayFile(ctx *fiber.Ctx) error { - req := new(PlayRequest) + path := ctx.Query("path") - err := ctx.BodyParser(req) + if path == "" { + return errors.New("inexistent path") + } + + decoded, err := hex.DecodeString(path) if err != nil { return err } root := config.Instance().GetConfig().DownloadPath - if strings.Contains(filepath.Dir(req.Path), root) { - return ctx.SendFile(req.Path) + //TODO: further path / file validations + + if strings.Contains(filepath.Dir(string(decoded)), root) { + ctx.SendStatus(fiber.StatusPartialContent) + return ctx.SendFile(string(decoded)) } + ctx.Status(fiber.StatusOK) return ctx.SendStatus(fiber.StatusUnauthorized) } diff --git a/server/server.go b/server/server.go index 047d509..2926111 100644 --- a/server/server.go +++ b/server/server.go @@ -34,7 +34,7 @@ func RunBlocking(port int, frontend fs.FS) { app.Get("/downloaded", rest.ListDownloaded) app.Post("/delete", rest.DeleteFile) - app.Post("/play", rest.PlayFile) + app.Get("/play", rest.PlayFile) // RPC handlers // websocket