Download REST API endpoints (#72)

* backend and frontend hotfixes, see message

Improved rendering on the frontend by cutting unecessary useStates.
Backend side, downloads now auto resume even on application kill.

* download rest api endpoints, general code refactor

* download request json mappings
This commit is contained in:
Marco
2023-07-31 08:30:09 +02:00
committed by GitHub
parent 68c829c40e
commit 8327d1e94c
22 changed files with 560 additions and 300 deletions

View File

@@ -1,14 +1,11 @@
import { ThemeProvider } from '@emotion/react' import { ThemeProvider } from '@emotion/react'
import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard' import Dashboard from '@mui/icons-material/Dashboard'
import Menu from '@mui/icons-material/Menu' import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings' import SettingsIcon from '@mui/icons-material/Settings'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet' import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import Storage from '@mui/icons-material/Storage' import Storage from '@mui/icons-material/Storage'
import { Box, createTheme } from '@mui/material' import { Box, createTheme } from '@mui/material'
import DownloadIcon from '@mui/icons-material/Download' import DownloadIcon from '@mui/icons-material/Download'
import CssBaseline from '@mui/material/CssBaseline' import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
@@ -19,20 +16,16 @@ import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors' import { grey } from '@mui/material/colors'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet } from 'react-router-dom'
import { RootState } from './stores/store' import { RootState } from './stores/store'
import AppBar from './components/AppBar' import AppBar from './components/AppBar'
import Drawer from './components/Drawer' import Drawer from './components/Drawer'
import Logout from './components/Logout' import Logout from './components/Logout'
import ThemeToggler from './components/ThemeToggler' import ThemeToggler from './components/ThemeToggler'
import Toaster from './providers/ToasterProvider'
import I18nProvider from './providers/i18nProvider' import I18nProvider from './providers/i18nProvider'
import RPCClientProvider from './providers/rpcClientProvider' import RPCClientProvider from './providers/rpcClientProvider'
import { formatGiB } from './utils' import { formatGiB } from './utils'
@@ -184,6 +177,7 @@ export default function Layout() {
<Outlet /> <Outlet />
</Box> </Box>
</Box> </Box>
<Toaster />
</RPCClientProvider> </RPCClientProvider>
</I18nProvider> </I18nProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -1,8 +1,9 @@
import { Grid, Snackbar } from "@mui/material" import { Grid } from "@mui/material"
import { Fragment, useContext, useEffect, useState } from "react" import { Fragment, useContext } from "react"
import { useToast } from "../hooks/toast"
import { I18nContext } from "../providers/i18nProvider"
import type { RPCResult } from "../types" import type { RPCResult } from "../types"
import { StackableResult } from "./StackableResult" import { StackableResult } from "./StackableResult"
import { I18nContext } from "../providers/i18nProvider"
type Props = { type Props = {
downloads: RPCResult[] downloads: RPCResult[]
@@ -10,9 +11,8 @@ type Props = {
} }
export function DownloadsCardView({ downloads, onStop }: Props) { export function DownloadsCardView({ downloads, onStop }: Props) {
const [openSB, setOpenSB] = useState(false)
const { i18n } = useContext(I18nContext) const { i18n } = useContext(I18nContext)
const { pushMessage } = useToast()
return ( return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}> <Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
@@ -26,7 +26,7 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
thumbnail={download.info.thumbnail} thumbnail={download.info.thumbnail}
percentage={download.progress.percentage} percentage={download.progress.percentage}
onStop={() => onStop(download.id)} onStop={() => onStop(download.id)}
onCopy={() => setOpenSB(true)} onCopy={() => pushMessage(i18n.t('clipboardAction'))}
resolution={download.info.resolution ?? ''} resolution={download.info.resolution ?? ''}
speed={download.progress.speed} speed={download.progress.speed}
size={download.info.filesize_approx ?? 0} size={download.info.filesize_approx ?? 0}
@@ -36,12 +36,6 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
</Grid> </Grid>
)) ))
} }
<Snackbar
open={openSB}
autoHideDuration={1250}
onClose={() => setOpenSB(false)}
message={i18n.t('clipboardAction')}
/>
</Grid> </Grid>
) )
} }

View File

@@ -19,13 +19,13 @@ type Props = {
onStop: (id: string) => void onStop: (id: string) => void
} }
export function DownloadsListView({ downloads, onStop }: Props) { export const DownloadsListView: React.FC<Props> = ({ downloads, onStop }) => {
return ( return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}> <Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
<Grid item xs={12}> <Grid item xs={12}>
<TableContainer component={Paper} sx={{ minHeight: '80vh' }} elevation={2}> <TableContainer component={Paper} sx={{ minHeight: '100%' }} elevation={2}>
<Table> <Table>
<TableHead> <TableHead hidden={downloads.length === 0}>
<TableRow> <TableRow>
<TableCell> <TableCell>
<Typography fontWeight={500} fontSize={15}>Title</Typography> <Typography fontWeight={500} fontSize={15}>Title</Typography>
@@ -52,8 +52,9 @@ export function DownloadsListView({ downloads, onStop }: Props) {
<TableCell> <TableCell>
<LinearProgress <LinearProgress
value={ value={
download.progress.percentage === '-1' ? 100 : download.progress.percentage === '-1'
Number(download.progress.percentage.replace('%', '')) ? 100
: Number(download.progress.percentage.replace('%', ''))
} }
variant={ variant={
download.progress.process_status === 0 download.progress.process_status === 0

View File

@@ -0,0 +1,46 @@
import { AlertColor } from '@mui/material'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface ToastState {
message: string
open: boolean
autoClose: boolean
severity?: AlertColor
}
type MessageAction = {
message: string,
severity?: AlertColor
}
const initialState: ToastState = {
message: '',
open: false,
autoClose: true,
}
export const toastSlice = createSlice({
name: 'toast',
initialState,
reducers: {
setMessage: (state, action: PayloadAction<MessageAction>) => {
state.message = action.payload.message
state.severity = action.payload.severity
state.open = true
},
setOpen: (state) => {
state.open = true
},
setClose: (state) => {
state.open = false
},
}
})
export const {
setMessage,
setClose,
setOpen,
} = toastSlice.actions
export default toastSlice.reducer

View File

@@ -0,0 +1,16 @@
import { useDispatch } from "react-redux"
import { setMessage } from "../features/ui/toastSlice"
import { AlertColor } from "@mui/material"
export const useToast = () => {
const dispatch = useDispatch()
return {
pushMessage: (message: string, severity?: AlertColor) => {
dispatch(setMessage({
message: message,
severity: severity
}))
}
}
}

View File

@@ -0,0 +1,23 @@
import { Alert, Snackbar } from "@mui/material"
import { useDispatch, useSelector } from "react-redux"
import { setClose } from "../features/ui/toastSlice"
import { RootState } from "../stores/store"
const Toaster: React.FC = () => {
const toast = useSelector((state: RootState) => state.toast)
const dispatch = useDispatch()
return (
<Snackbar
open={toast.open}
autoHideDuration={toast.severity === 'error' ? 10000 : 1500}
onClose={() => dispatch(setClose())}
>
<Alert variant="filled" severity={toast.severity}>
{toast.message}
</Alert>
</Snackbar>
)
}
export default Toaster

View File

@@ -1,12 +1,14 @@
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit'
import settingsReducer from '../features/settings/settingsSlice' import settingsReducer from '../features/settings/settingsSlice'
import statussReducer from '../features/status/statusSlice' import statussReducer from '../features/status/statusSlice'
import toastReducer from '../features/ui/toastSlice'
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
settings: settingsReducer, settings: settingsReducer,
status: statussReducer, status: statussReducer,
}, toast: toastReducer,
},
}) })
export type RootState = ReturnType<typeof store.getState> export type RootState = ReturnType<typeof store.getState>

View File

@@ -2,11 +2,9 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever' import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import { import {
Alert,
Backdrop, Backdrop,
CircularProgress, CircularProgress,
Container, Container,
Snackbar,
SpeedDial, SpeedDial,
SpeedDialAction, SpeedDialAction,
SpeedDialIcon SpeedDialIcon
@@ -19,6 +17,7 @@ import { DownloadsListView } from '../components/DownloadsListView'
import Splash from '../components/Splash' import Splash from '../components/Splash'
import { toggleListView } from '../features/settings/settingsSlice' import { toggleListView } from '../features/settings/settingsSlice'
import { connected, setFreeSpace } from '../features/status/statusSlice' import { connected, setFreeSpace } from '../features/status/statusSlice'
import { useToast } from '../hooks/toast'
import { socket$ } from '../lib/rpcClient' import { socket$ } from '../lib/rpcClient'
import { I18nContext } from '../providers/i18nProvider' import { I18nContext } from '../providers/i18nProvider'
import { RPCClientContext } from '../providers/rpcClientProvider' import { RPCClientContext } from '../providers/rpcClientProvider'
@@ -27,24 +26,19 @@ import type { RPCResponse, RPCResult } from '../types'
import { datetimeCompareFunc, isRPCResponse } from '../utils' import { datetimeCompareFunc, isRPCResponse } from '../utils'
export default function Home() { export default function Home() {
// redux state
const settings = useSelector((state: RootState) => state.settings) const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status) const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch() const dispatch = useDispatch()
// ephemeral state
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>() const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
const [showBackdrop, setShowBackdrop] = useState(true) const [showBackdrop, setShowBackdrop] = useState(true)
const [showToast, setShowToast] = useState(true)
const [openDialog, setOpenDialog] = useState(false) const [openDialog, setOpenDialog] = useState(false)
const [socketHasError, setSocketHasError] = useState(false)
// context
const { i18n } = useContext(I18nContext) const { i18n } = useContext(I18nContext)
const { client } = useContext(RPCClientContext) const { client } = useContext(RPCClientContext)
const { pushMessage } = useToast()
/* -------------------- Effects -------------------- */ /* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/ /* WebSocket connect event handler*/
@@ -56,13 +50,12 @@ export default function Home() {
dispatch(connected()) dispatch(connected())
}, },
error: () => { error: () => {
setSocketHasError(true) pushMessage(
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
"error"
)
setShowBackdrop(false) setShowBackdrop(false)
}, }
complete: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
}) })
return () => sub.unsubscribe() return () => sub.unsubscribe()
}, [socket$, status.connected]) }, [socket$, status.connected])
@@ -80,7 +73,10 @@ export default function Home() {
.freeSpace() .freeSpace()
.then(bytes => dispatch(setFreeSpace(bytes.result))) .then(bytes => dispatch(setFreeSpace(bytes.result)))
.catch(() => { .catch(() => {
setSocketHasError(true) pushMessage(
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
"error"
)
setShowBackdrop(false) setShowBackdrop(false)
}) })
}, []) }, [])
@@ -98,6 +94,12 @@ export default function Home() {
a.info.created_at, a.info.created_at,
))) )))
}) })
pushMessage(
`Connected to (${settings.serverAddr}:${settings.serverPort})`,
"success"
)
return () => sub.unsubscribe() return () => sub.unsubscribe()
}, [socket$, status.connected]) }, [socket$, status.connected])
@@ -107,11 +109,6 @@ export default function Home() {
} }
}, [activeDownloads?.length]) }, [activeDownloads?.length])
/**
* Abort a specific download if id's provided, other wise abort all running ones.
* @param id The download id / pid
* @returns void
*/
const abort = (id?: string) => { const abort = (id?: string) => {
if (id) { if (id) {
client.kill(id) client.kill(id)
@@ -136,20 +133,6 @@ export default function Home() {
<DownloadsListView downloads={activeDownloads ?? []} onStop={abort} /> : <DownloadsListView downloads={activeDownloads ?? []} onStop={abort} /> :
<DownloadsCardView downloads={activeDownloads ?? []} onStop={abort} /> <DownloadsCardView downloads={activeDownloads ?? []} onStop={abort} />
} }
<Snackbar
open={showToast === status.connected}
autoHideDuration={1500}
onClose={() => setShowToast(false)}
>
<Alert variant="filled" severity="success">
{`Connected to (${settings.serverAddr}:${settings.serverPort})`}
</Alert>
</Snackbar>
<Snackbar open={socketHasError}>
<Alert variant="filled" severity="error">
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
</Alert>
</Snackbar>
<SpeedDial <SpeedDial
ariaLabel="SpeedDial basic example" ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }} sx={{ position: 'absolute', bottom: 32, right: 32 }}

View File

@@ -11,7 +11,6 @@ import {
Paper, Paper,
Select, Select,
SelectChangeEvent, SelectChangeEvent,
Snackbar,
Stack, Stack,
Switch, Switch,
TextField, TextField,
@@ -39,7 +38,7 @@ import {
setServerPort, setServerPort,
setTheme setTheme
} from '../features/settings/settingsSlice' } from '../features/settings/settingsSlice'
import { updated } from '../features/status/statusSlice' import { useToast } from '../hooks/toast'
import { CliArguments } from '../lib/argsParser' import { CliArguments } from '../lib/argsParser'
import { I18nContext } from '../providers/i18nProvider' import { I18nContext } from '../providers/i18nProvider'
import { RPCClientContext } from '../providers/rpcClientProvider' import { RPCClientContext } from '../providers/rpcClientProvider'
@@ -49,7 +48,6 @@ import { validateDomain, validateIP } from '../utils'
export default function Settings() { export default function Settings() {
const dispatch = useDispatch() const dispatch = useDispatch()
const status = useSelector((state: RootState) => state.status)
const settings = useSelector((state: RootState) => state.settings) const settings = useSelector((state: RootState) => state.settings)
const [invalidIP, setInvalidIP] = useState(false); const [invalidIP, setInvalidIP] = useState(false);
@@ -57,6 +55,8 @@ export default function Settings() {
const { i18n } = useContext(I18nContext) const { i18n } = useContext(I18nContext)
const { client } = useContext(RPCClientContext) const { client } = useContext(RPCClientContext)
const { pushMessage } = useToast()
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), []) const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
@@ -110,10 +110,10 @@ export default function Settings() {
} }
/** /**
* Send via WebSocket a message to update yt-dlp binary * Updates yt-dlp binary via RPC
*/ */
const updateBinary = () => { const updateBinary = () => {
client.updateExecutable().then(() => dispatch(updated())) client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated')))
} }
return ( return (
@@ -270,7 +270,7 @@ export default function Settings() {
<Button <Button
sx={{ mr: 1, mt: 3 }} sx={{ mr: 1, mt: 3 }}
variant="contained" variant="contained"
onClick={() => dispatch(updated())} onClick={() => updateBinary()}
> >
{i18n.t('updateBinButton')} {i18n.t('updateBinButton')}
</Button> </Button>
@@ -280,12 +280,6 @@ export default function Settings() {
</Paper> </Paper>
</Grid> </Grid>
</Grid> </Grid>
<Snackbar
open={status.updated}
autoHideDuration={1500}
message={i18n.t('toastUpdated')}
onClose={updateBinary}
/>
</Container> </Container>
); );
} }

View File

@@ -1,7 +1,5 @@
package cli package cli
import "fmt"
const ( const (
// FG // FG
Red = "\033[31m" Red = "\033[31m"
@@ -12,12 +10,8 @@ const (
Cyan = "\033[36m" Cyan = "\033[36m"
Reset = "\033[0m" Reset = "\033[0m"
// BG // BG
BgRed = "\033[1;41m" BgRed = "\033[1;41m"
BgBlue = "\033[1;44m" BgBlue = "\033[1;44m"
BgGreen = "\033[1;42m" BgGreen = "\033[1;42m"
BgMagenta = "\033[1;45m"
) )
// Formats a message with the specified ascii escape code, then reset.
func Format(message string, code string) string {
return fmt.Sprintf("%s%s%s", code, message, Reset)
}

157
server/handlers/archive.go Normal file
View File

@@ -0,0 +1,157 @@
package handlers
import (
"encoding/hex"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/goccy/go-json"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
)
const (
TOKEN_COOKIE_NAME = "jwt"
)
type DirectoryEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
SHASum string `json:"shaSum"`
ModTime time.Time `json:"modTime"`
IsVideo bool `json:"isVideo"`
IsDirectory bool `json:"isDirectory"`
}
func walkDir(root string) (*[]DirectoryEntry, error) {
files := []DirectoryEntry{}
dirs, err := os.ReadDir(root)
if err != nil {
return nil, err
}
for _, d := range dirs {
if !utils.IsValidEntry(d) {
continue
}
path := filepath.Join(root, d.Name())
info, err := d.Info()
if err != nil {
return nil, err
}
files = append(files, DirectoryEntry{
Path: path,
Name: d.Name(),
Size: info.Size(),
SHASum: utils.ShaSumString(path),
IsVideo: utils.IsVideo(d),
IsDirectory: d.IsDir(),
ModTime: info.ModTime(),
})
}
return &files, err
}
type ListRequest struct {
SubDir string `json:"subdir"`
OrderBy string `json:"orderBy"`
}
func ListDownloaded(w http.ResponseWriter, r *http.Request) {
root := config.Instance().GetConfig().DownloadPath
req := new(ListRequest)
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files, err := walkDir(filepath.Join(root, req.SubDir))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.OrderBy == "modtime" {
sort.SliceStable(*files, func(i, j int) bool {
return (*files)[i].ModTime.After((*files)[j].ModTime)
})
}
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(files)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
type DeleteRequest = DirectoryEntry
func DeleteFile(w http.ResponseWriter, r *http.Request) {
req := new(DeleteRequest)
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sum := utils.ShaSumString(req.Path)
if sum != req.SHASum {
http.Error(w, "shasum mismatch", http.StatusBadRequest)
return
}
err = os.Remove(req.Path)
if err != nil {
http.Error(w, "shasum mismatch", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode("ok")
}
func SendFile(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "id")
if path == "" {
http.Error(w, "inexistent path", http.StatusBadRequest)
return
}
decoded, err := hex.DecodeString(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
decodedStr := string(decoded)
root := config.Instance().GetConfig().DownloadPath
// TODO: further path / file validations
if strings.Contains(filepath.Dir(decodedStr), root) {
// ctx.Response().Header.Set(
// "Content-Disposition",
// "inline; filename="+filepath.Base(decodedStr),
// )
http.ServeFile(w, r, decodedStr)
}
w.WriteHeader(http.StatusUnauthorized)
}

65
server/handlers/login.go Normal file
View File

@@ -0,0 +1,65 @@
package handlers
import (
"net/http"
"os"
"time"
"github.com/goccy/go-json"
"github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
type LoginRequest struct {
Secret string `json:"secret"`
}
func Login(w http.ResponseWriter, r *http.Request) {
req := new(LoginRequest)
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if config.Instance().GetConfig().RPCSecret != req.Secret {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
expiresAt := time.Now().Add(time.Hour * 24 * 30)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"expiresAt": expiresAt,
})
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
Expires: expiresAt, // 30 days
Value: tokenString,
Path: "/",
}
http.SetCookie(w, cookie)
}
func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
Expires: time.Now(),
Value: "",
Path: "/",
}
http.SetCookie(w, cookie)
}

View File

@@ -66,8 +66,8 @@ type AbortRequest struct {
// struct representing the intent to start a download // struct representing the intent to start a download
type DownloadRequest struct { type DownloadRequest struct {
Id string Id string
URL string URL string `json:"url"`
Path string Path string `json:"path"`
Rename string Rename string `json:"rename"`
Params []string Params []string `json:"params"`
} }

View File

@@ -125,12 +125,18 @@ func (m *MemoryDB) Restore() {
} }
for _, proc := range session.Processes { for _, proc := range session.Processes {
m.table.Store(proc.Id, &Process{ restored := &Process{
Id: proc.Id, Id: proc.Id,
Url: proc.Info.URL, Url: proc.Info.URL,
Info: proc.Info, Info: proc.Info,
Progress: proc.Progress, Progress: proc.Progress,
}) }
m.table.Store(proc.Id, restored)
if restored.Progress.Percentage != "-1" {
go restored.Start()
}
} }
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset) log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)

View File

@@ -16,6 +16,7 @@ import (
"time" "time"
"github.com/marcopeocchi/fazzoletti/slices" "github.com/marcopeocchi/fazzoletti/slices"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
@@ -136,8 +137,11 @@ func (p *Process) Start() {
Speed: stdout.Speed, Speed: stdout.Speed,
ETA: stdout.Eta, ETA: stdout.Eta,
} }
shortId := strings.Split(p.Id, "-")[0] log.Println(
log.Printf("[%s] %s %s\n", shortId, p.Url, p.Progress.Percentage) cli.BgGreen, "DL", cli.Reset,
cli.BgBlue, p.getShortId(), cli.Reset,
p.Url, stdout.Percentage,
)
} }
} }
}() }()
@@ -156,6 +160,14 @@ func (p *Process) Complete() {
Speed: 0, Speed: 0,
ETA: 0, ETA: 0,
} }
shortId := p.getShortId()
log.Println(
cli.BgMagenta, "FINISH", cli.Reset,
cli.BgBlue, shortId, cli.Reset,
p.Url,
)
} }
// Kill a process and remove it from the memory // Kill a process and remove it from the memory
@@ -202,6 +214,12 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
return DownloadFormats{}, err return DownloadFormats{}, err
} }
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, p.getShortId(), cli.Reset,
p.Url,
)
go func() { go func() {
decodingError = json.NewDecoder(stdout).Decode(&info) decodingError = json.NewDecoder(stdout).Decode(&info)
wg.Done() wg.Done()
@@ -248,6 +266,12 @@ func (p *Process) SetMetadata() error {
return err return err
} }
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, p.getShortId(), cli.Reset,
p.Url,
)
err = json.NewDecoder(stdout).Decode(&info) err = json.NewDecoder(stdout).Decode(&info)
if err != nil { if err != nil {
return err return err
@@ -260,3 +284,7 @@ func (p *Process) SetMetadata() error {
return err return err
} }
func (p *Process) getShortId() string {
return strings.Split(p.Id, "-")[0]
}

25
server/rest/container.go Normal file
View File

@@ -0,0 +1,25 @@
package rest
import (
"github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
)
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Handler {
var (
service = ProvideService(db, mq)
handler = ProvideHandler(service)
)
return handler
}
func ApplyRouter(db *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) {
h := Container(db, mq)
return func(r chi.Router) {
r.Use(middlewares.Authenticated)
r.Post("/exec", h.Exec())
r.Get("/running", h.Running())
}
}

View File

@@ -1,212 +1,56 @@
package rest package rest
import ( import (
"encoding/hex"
"net/http" "net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"github.com/golang-jwt/jwt/v5" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
) )
const ( type Handler struct {
TOKEN_COOKIE_NAME = "jwt" service *Service
)
type DirectoryEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
SHASum string `json:"shaSum"`
ModTime time.Time `json:"modTime"`
IsVideo bool `json:"isVideo"`
IsDirectory bool `json:"isDirectory"`
} }
func walkDir(root string) (*[]DirectoryEntry, error) { func (h *Handler) Exec() http.HandlerFunc {
files := []DirectoryEntry{} return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
dirs, err := os.ReadDir(root) w.Header().Set("Content-Type", "application/json")
if err != nil {
return nil, err
}
for _, d := range dirs { req := internal.DownloadRequest{}
if !utils.IsValidEntry(d) {
continue if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
} }
path := filepath.Join(root, d.Name()) id, err := h.service.Exec(req)
info, err := d.Info()
if err != nil { if err != nil {
return nil, err http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
files = append(files, DirectoryEntry{ err = json.NewEncoder(w).Encode(id)
Path: path, if err != nil {
Name: d.Name(), http.Error(w, err.Error(), http.StatusInternalServerError)
Size: info.Size(), }
SHASum: utils.ShaSumString(path),
IsVideo: utils.IsVideo(d),
IsDirectory: d.IsDir(),
ModTime: info.ModTime(),
})
}
return &files, err
}
type ListRequest struct {
SubDir string `json:"subdir"`
OrderBy string `json:"orderBy"`
}
func ListDownloaded(w http.ResponseWriter, r *http.Request) {
root := config.Instance().GetConfig().DownloadPath
req := new(ListRequest)
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files, err := walkDir(filepath.Join(root, req.SubDir))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.OrderBy == "modtime" {
sort.SliceStable(*files, func(i, j int) bool {
return (*files)[i].ModTime.After((*files)[j].ModTime)
})
}
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(files)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
type DeleteRequest = DirectoryEntry func (h *Handler) Running() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
func DeleteFile(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json")
req := new(DeleteRequest)
err := json.NewDecoder(r.Body).Decode(&req) res, err := h.service.Running(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
}
err = json.NewEncoder(w).Encode(res)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} }
sum := utils.ShaSumString(req.Path)
if sum != req.SHASum {
http.Error(w, "shasum mismatch", http.StatusBadRequest)
return
}
err = os.Remove(req.Path)
if err != nil {
http.Error(w, "shasum mismatch", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode("ok")
}
func SendFile(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "id")
if path == "" {
http.Error(w, "inexistent path", http.StatusBadRequest)
return
}
decoded, err := hex.DecodeString(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
decodedStr := string(decoded)
root := config.Instance().GetConfig().DownloadPath
// TODO: further path / file validations
if strings.Contains(filepath.Dir(decodedStr), root) {
// ctx.Response().Header.Set(
// "Content-Disposition",
// "inline; filename="+filepath.Base(decodedStr),
// )
http.ServeFile(w, r, decodedStr)
}
w.WriteHeader(http.StatusUnauthorized)
}
type LoginRequest struct {
Secret string `json:"secret"`
}
func Login(w http.ResponseWriter, r *http.Request) {
req := new(LoginRequest)
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if config.Instance().GetConfig().RPCSecret != req.Secret {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
expiresAt := time.Now().Add(time.Hour * 24 * 30)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"expiresAt": expiresAt,
})
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
Expires: expiresAt, // 30 days
Value: tokenString,
Path: "/",
}
http.SetCookie(w, cookie)
}
func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
Expires: time.Now(),
Value: "",
Path: "/",
}
http.SetCookie(w, cookie)
} }

34
server/rest/provider.go Normal file
View File

@@ -0,0 +1,34 @@
package rest
import (
"sync"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
var (
service *Service
handler *Handler
serviceOnce sync.Once
handlerOnce sync.Once
)
func ProvideService(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
serviceOnce.Do(func() {
service = &Service{
db: db,
mq: mq,
}
})
return service
}
func ProvideHandler(svc *Service) *Handler {
handlerOnce.Do(func() {
handler = &Handler{
service: svc,
}
})
return handler
}

38
server/rest/service.go Normal file
View File

@@ -0,0 +1,38 @@
package rest
import (
"context"
"errors"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
type Service struct {
db *internal.MemoryDB
mq *internal.MessageQueue
}
func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
p := &internal.Process{
Url: req.URL,
Params: req.Params,
Output: internal.DownloadOutput{
Path: req.Path,
Filename: req.Rename,
},
}
id := s.db.Set(p)
s.mq.Publish(p)
return id, nil
}
func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, error) {
select {
case <-ctx.Done():
return nil, errors.New("context cancelled")
default:
return s.db.All(), nil
}
}

24
server/rpc/container.go Normal file
View File

@@ -0,0 +1,24 @@
package rpc
import (
"github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
)
// Dependency injection container.
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
return &Service{
db: db,
mq: mq,
}
}
// RPC service must be registered before applying this router!
func ApplyRouter() func(chi.Router) {
return func(r chi.Router) {
r.Use(middlewares.Authenticated)
r.Get("/ws", WebSocket)
r.Post("/http", Post)
}
}

View File

@@ -24,14 +24,6 @@ type Args struct {
Params []string Params []string
} }
// Dependency injection container.
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
return &Service{
db: db,
mq: mq,
}
}
// Exec spawns a Process. // Exec spawns a Process.
// The result of the execution is the newly spawned process Id. // The result of the execution is the newly spawned process Id.
func (s *Service) Exec(args internal.DownloadRequest, result *string) error { func (s *Service) Exec(args internal.DownloadRequest, result *string) error {

View File

@@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest" "github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
@@ -68,23 +69,22 @@ func newServer(c serverConfig) *http.Server {
// Archive routes // Archive routes
r.Route("/archive", func(r chi.Router) { r.Route("/archive", func(r chi.Router) {
r.Use(middlewares.Authenticated) r.Use(middlewares.Authenticated)
r.Post("/downloaded", rest.ListDownloaded) r.Post("/downloaded", handlers.ListDownloaded)
r.Post("/delete", rest.DeleteFile) r.Post("/delete", handlers.DeleteFile)
r.Get("/d/{id}", rest.SendFile) r.Get("/d/{id}", handlers.SendFile)
}) })
// Authentication routes // Authentication routes
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/login", rest.Login) r.Post("/login", handlers.Login)
r.Get("/logout", rest.Logout) r.Get("/logout", handlers.Logout)
}) })
// RPC handlers // RPC handlers
r.Route("/rpc", func(r chi.Router) { r.Route("/rpc", ytdlpRPC.ApplyRouter())
r.Use(middlewares.Authenticated)
r.Get("/ws", ytdlpRPC.WebSocket) // REST API handlers
r.Post("/http", ytdlpRPC.Post) r.Route("/api/v1", rest.ApplyRouter(c.db, c.mq))
})
return &http.Server{ return &http.Server{
Addr: fmt.Sprintf(":%d", c.port), Addr: fmt.Sprintf(":%d", c.port),