first implementation of downloaded files viewer

This commit is contained in:
2023-05-24 13:19:04 +02:00
parent 3737e86de3
commit 908f4c6636
7 changed files with 276 additions and 31 deletions

View File

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

View 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
}

View File

@@ -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'>

View File

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

View File

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

View File

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