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