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)}
+ />
+
+
+
+ )
+}
\ 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