first implementation of downloaded files viewer
This commit is contained in:
@@ -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 (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Router>
|
||||
<BrowserRouter>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position="absolute" open={open}>
|
||||
@@ -180,10 +181,15 @@ function AppContent() {
|
||||
<Settings />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/downloaded" element={
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Downloaded />
|
||||
</Suspense>
|
||||
} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</Router>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
164
frontend/src/Downloaded.tsx
Normal file
164
frontend/src/Downloaded.tsx
Normal file
@@ -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<DirectoryEntry[]>([]), [])
|
||||
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
||||
|
||||
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 (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={selectable.length === 0}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Backdrop>
|
||||
<Paper sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||
{selectable.map((file) => (
|
||||
<ListItem
|
||||
key={file.shaSum}
|
||||
secondaryAction={
|
||||
<Checkbox
|
||||
edge="end"
|
||||
checked={file.selected}
|
||||
onChange={() => addSelected(file.name)}
|
||||
/>
|
||||
}
|
||||
disablePadding
|
||||
>
|
||||
<ListItemButton>
|
||||
<ListItemIcon>
|
||||
<VideoFileIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={file.name} onClick={() => window.open(
|
||||
`${serverAddr}/play?path=${Buffer.from(file.path).toString('hex')}`
|
||||
)} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
<SpeedDial
|
||||
ariaLabel="SpeedDial basic example"
|
||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||
icon={<SpeedDialIcon />}
|
||||
>
|
||||
<SpeedDialAction
|
||||
icon={<DeleteForeverIcon />}
|
||||
tooltipTitle={`Delete selected`}
|
||||
tooltipOpen
|
||||
onClick={() => setOpenDialog(true)}
|
||||
/>
|
||||
</SpeedDial>
|
||||
<Dialog
|
||||
open={openDialog}
|
||||
onClose={() => setOpenDialog(false)}
|
||||
>
|
||||
<DialogTitle>
|
||||
Are you sure?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
You're deleting:
|
||||
</DialogContentText>
|
||||
<ul>
|
||||
{selected$.value.map((entry, idx) => (
|
||||
<li key={idx}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
|
||||
<Button onClick={() => {
|
||||
deleteSelected()
|
||||
setOpenDialog(false)
|
||||
}} autoFocus>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
44
frontend/src/hooks/observable.ts
Normal file
44
frontend/src/hooks/observable.ts
Normal file
@@ -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<T>(
|
||||
source$: Observable<T>,
|
||||
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<T>(
|
||||
source$: Observable<T>,
|
||||
initialState: T,
|
||||
errHandler?: (err: any) => void,
|
||||
): T {
|
||||
const [value, setValue] = useState(initialState)
|
||||
|
||||
useSubscription(source$, setValue, errHandler)
|
||||
|
||||
return value
|
||||
}
|
||||
43
frontend/src/types/index.d.ts
vendored
43
frontend/src/types/index.d.ts
vendored
@@ -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<T> = {
|
||||
result: T,
|
||||
result: T
|
||||
error: number | null
|
||||
id?: string
|
||||
}
|
||||
@@ -46,18 +46,29 @@ export type RPCParams = {
|
||||
}
|
||||
|
||||
export interface DLMetadata {
|
||||
formats: Array<DLFormat>,
|
||||
best: DLFormat,
|
||||
thumbnail: string,
|
||||
title: string,
|
||||
formats: Array<DLFormat>
|
||||
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,
|
||||
}
|
||||
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<DirectoryEntry, 'name'>
|
||||
|
||||
export type PlayRequest = Omit<DirectoryEntry, 'shaSum' | 'name'>
|
||||
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user