Merge pull request #51 from marcopeocchi/50-request-for-download-link-and-option-to-delete-downloadded-video
50 request for download link and option to delete downloadded video
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ downloads
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
build/
|
build/
|
||||||
yt-dlp-webui
|
yt-dlp-webui
|
||||||
|
session.dat
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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 FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
|
||||||
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'
|
||||||
@@ -15,24 +14,25 @@ import CssBaseline from '@mui/material/CssBaseline'
|
|||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
import List from '@mui/material/List'
|
import List from '@mui/material/List'
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton'
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
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 DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
|
||||||
import ListItemButton from '@mui/material/ListItemButton'
|
|
||||||
import { grey } from '@mui/material/colors'
|
import { grey } from '@mui/material/colors'
|
||||||
|
|
||||||
import { Suspense, lazy, useMemo, useState } from 'react'
|
import { Suspense, lazy, useMemo, useState } from 'react'
|
||||||
import { Provider, useDispatch, useSelector } from 'react-redux'
|
import { Provider, useDispatch, useSelector } from 'react-redux'
|
||||||
import {
|
|
||||||
Link, Route,
|
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
|
||||||
BrowserRouter as Router,
|
import { RootState, store } from './stores/store'
|
||||||
Routes
|
|
||||||
} from 'react-router-dom'
|
|
||||||
import AppBar from './components/AppBar'
|
import AppBar from './components/AppBar'
|
||||||
import Drawer from './components/Drawer'
|
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'
|
import { formatGiB } from './utils'
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
@@ -40,7 +40,6 @@ function AppContent() {
|
|||||||
|
|
||||||
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 mode = settings.theme
|
const mode = settings.theme
|
||||||
const theme = useMemo(() =>
|
const theme = useMemo(() =>
|
||||||
@@ -63,7 +62,7 @@ function AppContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<Router>
|
<BrowserRouter>
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<AppBar position="absolute" open={open}>
|
<AppBar position="absolute" open={open}>
|
||||||
@@ -139,12 +138,19 @@ function AppContent() {
|
|||||||
<ListItemText primary="Home" />
|
<ListItemText primary="Home" />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<ListItemButton onClick={() => dispatch(toggleListView())}>
|
<Link to={'/archive'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton disabled={status.downloading}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<FormatListBulleted />
|
<DownloadIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="List view" />
|
<ListItemText primary="Archive" />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
<Link to={'/settings'} style={
|
<Link to={'/settings'} style={
|
||||||
{
|
{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -180,10 +186,15 @@ function AppContent() {
|
|||||||
<Settings />
|
<Settings />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/archive" element={
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Archive />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Router>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
169
frontend/src/Archive.tsx
Normal file
169
frontend/src/Archive.tsx
Normal file
@@ -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<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={!(files$.observed)}
|
||||||
|
>
|
||||||
|
<CircularProgress color="primary" />
|
||||||
|
</Backdrop>
|
||||||
|
<Paper sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||||
|
{selectable.length === 0 && 'No files found'}
|
||||||
|
{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={() => {
|
||||||
|
if (selected$.value.length > 0) {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FileUpload } from '@mui/icons-material'
|
import { FileUpload } from '@mui/icons-material'
|
||||||
|
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Backdrop,
|
Backdrop,
|
||||||
@@ -14,6 +15,9 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
|
SpeedDial,
|
||||||
|
SpeedDialAction,
|
||||||
|
SpeedDialIcon,
|
||||||
styled,
|
styled,
|
||||||
TextField
|
TextField
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
@@ -26,6 +30,7 @@ import FormatsGrid from './components/FormatsGrid'
|
|||||||
import { CliArguments } from './features/core/argsParser'
|
import { CliArguments } from './features/core/argsParser'
|
||||||
import I18nBuilder from './features/core/intl'
|
import I18nBuilder from './features/core/intl'
|
||||||
import { RPCClient, socket$ } from './features/core/rpcClient'
|
import { RPCClient, socket$ } from './features/core/rpcClient'
|
||||||
|
import { toggleListView } from './features/settings/settingsSlice'
|
||||||
import { connected, setFreeSpace } from './features/status/statusSlice'
|
import { connected, setFreeSpace } from './features/status/statusSlice'
|
||||||
import { RootState } from './stores/store'
|
import { RootState } from './stores/store'
|
||||||
import type { DLMetadata, RPCResponse, RPCResult } from './types'
|
import type { DLMetadata, RPCResponse, RPCResult } from './types'
|
||||||
@@ -405,6 +410,18 @@ export default function Home() {
|
|||||||
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
|
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel="SpeedDial basic example"
|
||||||
|
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||||
|
icon={<SpeedDialIcon />}
|
||||||
|
>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<FormatListBulleted />}
|
||||||
|
tooltipTitle={`Table view`}
|
||||||
|
tooltipOpen
|
||||||
|
onClick={() => dispatch(toggleListView())}
|
||||||
|
/>
|
||||||
|
</SpeedDial>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { StackableResult } from "./StackableResult"
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
downloads: RPCResult[]
|
downloads: RPCResult[]
|
||||||
abortFunction: Function
|
abortFunction: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadsCardView({ downloads, abortFunction }: Props) {
|
export function DownloadsCardView({ downloads, abortFunction }: Props) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EightK, FourK, Hd, Sd } from "@mui/icons-material";
|
import { EightK, FourK, Hd, Sd } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Typography
|
Typography
|
||||||
} from "@mui/material";
|
} from '@mui/material'
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react'
|
||||||
import { ellipsis, formatSpeedMiB, roundMiB } from "../utils";
|
import { ellipsis, formatSpeedMiB, roundMiB } from '../utils'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string,
|
title: string,
|
||||||
@@ -97,7 +97,8 @@ export function StackableResult({
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={stopCallback}>
|
onClick={stopCallback}
|
||||||
|
>
|
||||||
{isCompleted ? "Clear" : "Stop"}
|
{isCompleted ? "Clear" : "Stop"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
41
frontend/src/types/index.d.ts
vendored
41
frontend/src/types/index.d.ts
vendored
@@ -10,13 +10,13 @@ export type RPCMethods =
|
|||||||
| "Service.UpdateExecutable"
|
| "Service.UpdateExecutable"
|
||||||
|
|
||||||
export type RPCRequest = {
|
export type RPCRequest = {
|
||||||
method: RPCMethods,
|
method: RPCMethods
|
||||||
params?: any[],
|
params?: any[]
|
||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RPCResponse<T> = {
|
export type RPCResponse<T> = {
|
||||||
result: T,
|
result: T
|
||||||
error: number | null
|
error: number | null
|
||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
@@ -46,18 +46,29 @@ export type RPCParams = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DLMetadata {
|
export interface DLMetadata {
|
||||||
formats: Array<DLFormat>,
|
formats: Array<DLFormat>
|
||||||
best: DLFormat,
|
best: DLFormat
|
||||||
thumbnail: string,
|
thumbnail: string
|
||||||
title: string,
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DLFormat {
|
export type DLFormat = {
|
||||||
format_id: string,
|
format_id: string
|
||||||
format_note: string,
|
format_note: string
|
||||||
fps: number,
|
fps: number
|
||||||
resolution: string,
|
resolution: string
|
||||||
vcodec: string,
|
vcodec: string
|
||||||
acodec: string,
|
acodec: string
|
||||||
filesize_approx: number,
|
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`
|
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) {
|
export function formatGiB(bytes: number) {
|
||||||
return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
||||||
}
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/gofiber/websocket/v2 v2.1.5
|
github.com/gofiber/websocket/v2 v2.1.5
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
|
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
|
golang.org/x/sys v0.7.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
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-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-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/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.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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
|||||||
@@ -108,4 +108,14 @@ func (m *MemoryDB) Restore() {
|
|||||||
feed, _ := os.ReadFile("session.dat")
|
feed, _ := os.ReadFile("session.dat")
|
||||||
session := Session{}
|
session := Session{}
|
||||||
json.Unmarshal(feed, &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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ func (p *Process) Kill() error {
|
|||||||
// has been spawned with setPgid = true. To properly kill
|
// has been spawned with setPgid = true. To properly kill
|
||||||
// all subprocesses a SIGTERM need to be sent to the correct
|
// all subprocesses a SIGTERM need to be sent to the correct
|
||||||
// process group
|
// process group
|
||||||
|
if p.proc != nil {
|
||||||
pgid, err := syscall.Getpgid(p.proc.Pid)
|
pgid, err := syscall.Getpgid(p.proc.Pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -179,6 +180,8 @@ func (p *Process) Kill() error {
|
|||||||
|
|
||||||
log.Println("Killed process", p.id)
|
log.Println("Killed process", p.id)
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the available format for this URL
|
// Returns the available format for this URL
|
||||||
|
|||||||
129
server/rest/handlers.go
Normal file
129
server/rest/handlers.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/rpc"
|
"net/rpc"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||||
"github.com/gofiber/websocket/v2"
|
"github.com/gofiber/websocket/v2"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
var db MemoryDB
|
var db MemoryDB
|
||||||
|
|
||||||
func RunBlocking(port int, frontend fs.FS) {
|
func RunBlocking(port int, frontend fs.FS) {
|
||||||
|
db.Restore()
|
||||||
|
|
||||||
service := new(Service)
|
service := new(Service)
|
||||||
rpc.Register(service)
|
rpc.Register(service)
|
||||||
|
|
||||||
@@ -30,6 +38,13 @@ func RunBlocking(port int, frontend fs.FS) {
|
|||||||
app.Get("/settings", func(c *fiber.Ctx) error {
|
app.Get("/settings", func(c *fiber.Ctx) error {
|
||||||
return c.Redirect("/")
|
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
|
// RPC handlers
|
||||||
// websocket
|
// websocket
|
||||||
@@ -65,5 +80,34 @@ func RunBlocking(port int, frontend fs.FS) {
|
|||||||
|
|
||||||
app.Server().StreamRequestBody = true
|
app.Server().StreamRequestBody = true
|
||||||
|
|
||||||
|
go periodicallyPersist()
|
||||||
|
go gracefulShutdown(app)
|
||||||
|
|
||||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,12 +72,15 @@ func (t *Service) Running(args NoArgs, running *Running) error {
|
|||||||
func (t *Service) Kill(args string, killed *string) error {
|
func (t *Service) Kill(args string, killed *string) error {
|
||||||
log.Println("Trying killing process with id", args)
|
log.Println("Trying killing process with id", args)
|
||||||
proc, err := db.Get(args)
|
proc, err := db.Get(args)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if proc != nil {
|
if proc != nil {
|
||||||
err = proc.Kill()
|
err = proc.Kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.Delete(proc.id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user