diff --git a/.gitignore b/.gitignore index 0f5b409..3e94f74 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ downloads .DS_Store build/ yt-dlp-webui +session.dat diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 81e58fe..ea32cdb 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,6 @@ import { ThemeProvider } from '@emotion/react' import ChevronLeft from '@mui/icons-material/ChevronLeft' import Dashboard from '@mui/icons-material/Dashboard' -import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import Menu from '@mui/icons-material/Menu' import SettingsIcon from '@mui/icons-material/Settings' import SettingsEthernet from '@mui/icons-material/SettingsEthernet' @@ -15,24 +14,25 @@ import CssBaseline from '@mui/material/CssBaseline' import Divider from '@mui/material/Divider' import IconButton from '@mui/material/IconButton' import List from '@mui/material/List' +import ListItemButton from '@mui/material/ListItemButton' 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 DownloadIcon from '@mui/icons-material/Download'; -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 { BrowserRouter, Link, Route, Routes } from 'react-router-dom' +import { RootState, store } from './stores/store' + import AppBar from './components/AppBar' import Drawer from './components/Drawer' -import { toggleListView } from './features/settings/settingsSlice' -import { RootState, store } from './stores/store' + +import Archive from './Archive' import { formatGiB } from './utils' function AppContent() { @@ -40,7 +40,6 @@ function AppContent() { const settings = useSelector((state: RootState) => state.settings) const status = useSelector((state: RootState) => state.status) - const dispatch = useDispatch() const mode = settings.theme const theme = useMemo(() => @@ -63,7 +62,7 @@ function AppContent() { return ( - + @@ -139,12 +138,19 @@ function AppContent() { - dispatch(toggleListView())}> - - - - - + + + + + + + + } /> + }> + + + } /> - + ) } diff --git a/frontend/src/Archive.tsx b/frontend/src/Archive.tsx new file mode 100644 index 0000000..6d601f8 --- /dev/null +++ b/frontend/src/Archive.tsx @@ -0,0 +1,169 @@ +import { + Backdrop, + Button, + Checkbox, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Paper, + SpeedDial, + SpeedDialAction, + SpeedDialIcon +} from '@mui/material' + +import DeleteForeverIcon from '@mui/icons-material/DeleteForever' +import VideoFileIcon from '@mui/icons-material/VideoFile' +import { Buffer } from 'buffer' +import { useEffect, useMemo, useState } from 'react' +import { useSelector } from 'react-redux' +import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs' +import { useObservable } from './hooks/observable' +import { RootState } from './stores/store' +import { DeleteRequest, DirectoryEntry } from './types' + +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 Subject(), []) + 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={!(files$.observed)} + > + + + + + {selectable.length === 0 && 'No files found'} + {selectable.map((file) => ( + addSelected(file.name)} + /> + } + disablePadding + > + + + + + window.open( + `${serverAddr}/play?path=${Buffer.from(file.path).toString('hex')}` + )} /> + + + ))} + + + } + > + } + tooltipTitle={`Delete selected`} + tooltipOpen + onClick={() => { + if (selected$.value.length > 0) { + 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/Home.tsx b/frontend/src/Home.tsx index 96dd33f..a680ef1 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -1,4 +1,5 @@ import { FileUpload } from '@mui/icons-material' +import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import { Alert, Backdrop, @@ -14,6 +15,9 @@ import { Paper, Select, Snackbar, + SpeedDial, + SpeedDialAction, + SpeedDialIcon, styled, TextField } from '@mui/material' @@ -26,6 +30,7 @@ import FormatsGrid from './components/FormatsGrid' import { CliArguments } from './features/core/argsParser' import I18nBuilder from './features/core/intl' import { RPCClient, socket$ } from './features/core/rpcClient' +import { toggleListView } from './features/settings/settingsSlice' import { connected, setFreeSpace } from './features/status/statusSlice' import { RootState } from './stores/store' import type { DLMetadata, RPCResponse, RPCResult } from './types' @@ -405,6 +410,18 @@ export default function Home() { {`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`} + } + > + } + tooltipTitle={`Table view`} + tooltipOpen + onClick={() => dispatch(toggleListView())} + /> + ); } diff --git a/frontend/src/components/DownloadsCardView.tsx b/frontend/src/components/DownloadsCardView.tsx index dea7d17..7e02d36 100644 --- a/frontend/src/components/DownloadsCardView.tsx +++ b/frontend/src/components/DownloadsCardView.tsx @@ -6,7 +6,7 @@ import { StackableResult } from "./StackableResult" type Props = { downloads: RPCResult[] - abortFunction: Function + abortFunction: (id: string) => void } export function DownloadsCardView({ downloads, abortFunction }: Props) { diff --git a/frontend/src/components/StackableResult.tsx b/frontend/src/components/StackableResult.tsx index 24a6211..382e6d1 100644 --- a/frontend/src/components/StackableResult.tsx +++ b/frontend/src/components/StackableResult.tsx @@ -1,4 +1,4 @@ -import { EightK, FourK, Hd, Sd } from "@mui/icons-material"; +import { EightK, FourK, Hd, Sd } from '@mui/icons-material' import { Button, Card, @@ -11,9 +11,9 @@ import { Skeleton, Stack, Typography -} from "@mui/material"; -import { useEffect, useState } from "react"; -import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"; +} from '@mui/material' +import { useEffect, useState } from 'react' +import { ellipsis, formatSpeedMiB, roundMiB } from '../utils' type Props = { title: string, @@ -97,7 +97,8 @@ export function StackableResult({ variant="contained" size="small" color="primary" - onClick={stopCallback}> + onClick={stopCallback} + > {isCompleted ? "Clear" : "Stop"} 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/go.mod b/go.mod index 35f9947..c08ce79 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gofiber/websocket/v2 v2.1.5 github.com/google/uuid v1.3.0 github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa + golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc golang.org/x/sys v0.7.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 74e695e..fef2991 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/server/memory_db.go b/server/memory_db.go index 28f8aaa..2562709 100644 --- a/server/memory_db.go +++ b/server/memory_db.go @@ -108,4 +108,14 @@ func (m *MemoryDB) Restore() { feed, _ := os.ReadFile("session.dat") session := Session{} json.Unmarshal(feed, &session) + + for _, proc := range session.Processes { + db.table.Store(proc.Id, &Process{ + id: proc.Id, + url: proc.Info.URL, + Info: proc.Info, + Progress: proc.Progress, + mem: m, + }) + } } diff --git a/server/process.go b/server/process.go index 8f0f3c8..d348319 100644 --- a/server/process.go +++ b/server/process.go @@ -171,14 +171,17 @@ func (p *Process) Kill() error { // has been spawned with setPgid = true. To properly kill // all subprocesses a SIGTERM need to be sent to the correct // process group - pgid, err := syscall.Getpgid(p.proc.Pid) - if err != nil { + if p.proc != nil { + pgid, err := syscall.Getpgid(p.proc.Pid) + if err != nil { + return err + } + err = syscall.Kill(-pgid, syscall.SIGTERM) + + log.Println("Killed process", p.id) return err } - err = syscall.Kill(-pgid, syscall.SIGTERM) - - log.Println("Killed process", p.id) - return err + return nil } // Returns the available format for this URL diff --git a/server/rest/handlers.go b/server/rest/handlers.go new file mode 100644 index 0000000..7a39aad --- /dev/null +++ b/server/rest/handlers.go @@ -0,0 +1,129 @@ +package rest + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/marcopeocchi/yt-dlp-web-ui/server/config" + "golang.org/x/exp/slices" +) + +type DirectoryEntry struct { + Name string `json:"name"` + Path string `json:"path"` + SHASum string `json:"shaSum"` +} + +func isValidEntry(d fs.DirEntry) bool { + return !d.IsDir() && + !strings.HasPrefix(d.Name(), ".") && + !strings.HasSuffix(d.Name(), ".part") && + !strings.HasSuffix(d.Name(), ".ytdl") +} + +func walkDir(root string) (*[]DirectoryEntry, error) { + files := []DirectoryEntry{} + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if isValidEntry(d) { + h := sha256.New() + h.Write([]byte(path)) + + files = append(files, DirectoryEntry{ + Path: path, + Name: d.Name(), + SHASum: hex.EncodeToString(h.Sum(nil)), + }) + } + return nil + }) + + return &files, err +} + +func ListDownloaded(ctx *fiber.Ctx) error { + root := config.Instance().GetConfig().DownloadPath + + files, err := walkDir(root) + if err != nil { + return err + } + + ctx.Status(http.StatusOK) + return ctx.JSON(files) +} + +type DeleteRequest = DirectoryEntry + +func DeleteFile(ctx *fiber.Ctx) error { + req := new(DeleteRequest) + + err := ctx.BodyParser(req) + if err != nil { + return err + } + + root := config.Instance().GetConfig().DownloadPath + + files, err := walkDir(root) + if err != nil { + return err + } + + index := slices.IndexFunc(*files, func(e DirectoryEntry) bool { + 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 { + return err + } + } + + ctx.Status(fiber.StatusOK) + return ctx.JSON(index) +} + +type PlayRequest struct { + Path string +} + +func PlayFile(ctx *fiber.Ctx) error { + path := ctx.Query("path") + + if path == "" { + return errors.New("inexistent path") + } + + decoded, err := hex.DecodeString(path) + if err != nil { + return err + } + + root := config.Instance().GetConfig().DownloadPath + + //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 12b6897..b430457 100644 --- a/server/server.go +++ b/server/server.go @@ -1,22 +1,30 @@ package server import ( + "context" "fmt" "io" "io/fs" "log" "net/http" "net/rpc" + "os" + "os/signal" + "syscall" + "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/websocket/v2" + "github.com/marcopeocchi/yt-dlp-web-ui/server/rest" ) var db MemoryDB func RunBlocking(port int, frontend fs.FS) { + db.Restore() + service := new(Service) rpc.Register(service) @@ -30,6 +38,13 @@ func RunBlocking(port int, frontend fs.FS) { app.Get("/settings", func(c *fiber.Ctx) error { return c.Redirect("/") }) + app.Get("/archive", func(c *fiber.Ctx) error { + return c.Redirect("/") + }) + + app.Get("/downloaded", rest.ListDownloaded) + app.Post("/delete", rest.DeleteFile) + app.Get("/play", rest.PlayFile) // RPC handlers // websocket @@ -65,5 +80,34 @@ func RunBlocking(port int, frontend fs.FS) { app.Server().StreamRequestBody = true + go periodicallyPersist() + go gracefulShutdown(app) + log.Fatal(app.Listen(fmt.Sprintf(":%d", port))) } + +func gracefulShutdown(app *fiber.App) { + ctx, stop := signal.NotifyContext(context.Background(), + os.Interrupt, + syscall.SIGTERM, + syscall.SIGQUIT, + ) + + go func() { + <-ctx.Done() + log.Println("shutdown signal received") + + defer func() { + db.Persist() + stop() + app.ShutdownWithTimeout(time.Second * 5) + }() + }() +} + +func periodicallyPersist() { + for { + db.Persist() + time.Sleep(time.Minute * 5) + } +} diff --git a/server/service.go b/server/service.go index 6d2de79..7d92407 100644 --- a/server/service.go +++ b/server/service.go @@ -72,12 +72,15 @@ func (t *Service) Running(args NoArgs, running *Running) error { func (t *Service) Kill(args string, killed *string) error { log.Println("Trying killing process with id", args) proc, err := db.Get(args) + if err != nil { return err } if proc != nil { err = proc.Kill() } + + db.Delete(proc.id) return err }