Compare commits

...

27 Commits

Author SHA1 Message Date
1e0e625d1a code refactoring, dependencies update 2023-05-26 11:29:59 +02:00
Marco
5b70d25bef Improved filebrowser (#52)
* file archive refactor, list dir perf optimization

* code refactoring
2023-05-26 11:10:10 +02:00
Marco
8cf130ec23 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
2023-05-25 11:42:01 +02:00
fd0b40ac46 code refactoring, enabled memory db persist to fs. 2023-05-25 11:13:46 +02:00
acfc5aa064 code refactoring 2023-05-24 13:31:48 +02:00
b1c6f7248c code refactoring 2023-05-24 13:31:05 +02:00
ac6fe98dc8 ui refactor, downloaded files view enabled 2023-05-24 13:29:54 +02:00
908f4c6636 first implementation of downloaded files viewer 2023-05-24 13:19:04 +02:00
3737e86de3 backend functions for list, download, and delete local files 2023-05-17 18:32:46 +02:00
Marco
77f9eb0c2a Update Home.tsx 2023-04-19 18:24:08 +02:00
e00333a97e reduced chunks size 2023-04-19 15:01:37 +02:00
3d86b4c372 bug fix 2023-04-19 14:16:43 +02:00
fa7cd1a691 code refactoring, switch to rxjs websocket wrapper 2023-04-19 14:14:15 +02:00
621164589f Code refactoring and bump deps 2023-04-13 11:13:40 +02:00
Marco
7f602f1e20 Update docker-publish.yml 2023-03-21 22:58:14 +01:00
Marco
5977a57686 Update docker-image.yml 2023-03-21 22:57:43 +01:00
Marco
73a557d318 Merge pull request #42 from mimaburao/test
update japanese
2023-03-04 00:03:53 +01:00
mimaburao
ae1da10d6e update japanese 2023-03-03 22:44:12 +09:00
Marco
cd7ce6f55c Merge pull request #41 from marcopeocchi/opt-sync-map
changed map+rwMutext to sync.Map
2023-03-01 15:09:20 +01:00
aaad68a42c changed map+rwMutext to sync.Map 2023-03-01 15:06:11 +01:00
Marco
72857882e4 Merge pull request #37 from cnbeining/fix-websocket-wss
Fix WebSocket protocol detecton under HTTPS
2023-02-19 13:14:12 +01:00
David Zhuang
59abd76966 Fix WebSocket protocol detecton under HTTPS 2023-02-18 17:51:01 -05:00
Marco
8ab7c4db4d Update Dockerfile 2023-02-18 00:14:26 +01:00
Marco
17d48354cb Merge pull request #35 from Skyr/show-selectformat-button
If formats selection enabled: Show "select format" string in button
2023-02-08 17:54:25 +01:00
Marco
ac54a1dd13 Merge pull request #34 from Skyr/show-download-size
In format selection: Show resolution and download size (if available)
2023-02-08 17:54:12 +01:00
Stefan Schlott
75c6c84c5c If formats selection enabled: Show "select format" string in button
(instead of start)
2023-02-04 12:24:42 +01:00
Stefan Schlott
cdad7ca873 In format selection: Show resolution and download size (if available) 2023-02-04 12:13:20 +01:00
34 changed files with 1186 additions and 561 deletions

View File

@@ -5,6 +5,8 @@ on:
branches: [ master ] branches: [ master ]
pull_request: pull_request:
branches: [ master ] branches: [ master ]
schedule:
- cron: '0 1 * * *'
jobs: jobs:
build: build:

View File

@@ -13,6 +13,8 @@ on:
branches: [ master ] branches: [ master ]
pull_request: pull_request:
branches: [ master ] branches: [ master ]
schedule:
- cron : '0 1 * * 0'
env: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ downloads
.DS_Store .DS_Store
build/ build/
yt-dlp-webui yt-dlp-webui
session.dat

View File

@@ -1,7 +1,4 @@
# Multi stage build Dockerfile FROM golang:1.20-alpine AS build
# There's no point in using the edge (development branch of alpine)
FROM alpine:3.17 AS build
# folder structure # folder structure
WORKDIR /usr/src/yt-dlp-webui WORKDIR /usr/src/yt-dlp-webui
# install core dependencies # install core dependencies
@@ -15,10 +12,10 @@ RUN npm install
RUN npm run build RUN npm run build
# build backend + incubator # build backend + incubator
WORKDIR /usr/src/yt-dlp-webui WORKDIR /usr/src/yt-dlp-webui
RUN go build -o yt-dlp-webui RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# but here yes :)
FROM alpine:3.17 FROM alpine:edge
WORKDIR /downloads WORKDIR /downloads
VOLUME /downloads VOLUME /downloads
@@ -28,8 +25,7 @@ WORKDIR /app
RUN apk update && \ RUN apk update && \
apk add psmisc ffmpeg yt-dlp apk add psmisc ffmpeg yt-dlp
COPY --from=build /usr/src/yt-dlp-webui /app COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
RUN chmod +x /app/yt-dlp-webui
EXPOSE 3033 EXPOSE 3033
CMD [ "./yt-dlp-webui" , "--out", "/downloads" ] CMD [ "./yt-dlp-webui" , "--out", "/downloads" ]

View File

@@ -1,14 +1,14 @@
default: default:
go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
all: all:
cd frontend && pnpm build && cd .. cd frontend && pnpm build && cd ..
go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
multiarch: multiarch:
GOOS=linux GOARCH=arm go build -o yt-dlp-webui_linux-arm *.go CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -o yt-dlp-webui_linux-arm main.go
GOOS=linux GOARCH=arm64 go build -o yt-dlp-webui_linux-arm64 *.go CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o yt-dlp-webui_linux-arm64 main.go
GOOS=linux GOARCH=amd64 go build -o yt-dlp-webui_linux-amd64 *.go CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o yt-dlp-webui_linux-amd64 main.go
mkdir -p build mkdir -p build
mv yt-dlp-webui* build mv yt-dlp-webui* build

View File

@@ -1,6 +1,6 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "2.0.6", "version": "2.0.7",
"description": "Frontend compontent of yt-dlp-webui", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,28 +9,28 @@
"author": "marcopeocchi", "author": "marcopeocchi",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.11.0",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.11.0", "@mui/icons-material": "^5.11.16",
"@mui/material": "^5.11.5", "@mui/material": "^5.13.2",
"@reduxjs/toolkit": "^1.9.1", "@reduxjs/toolkit": "^1.9.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^8.0.5", "react-redux": "^8.0.5",
"react-router-dom": "^6.7.0", "react-router-dom": "^6.11.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.1",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4", "@modyfi/vite-plugin-yaml": "^1.0.4",
"@types/node": "^18.11.18", "@types/node": "^20.2.4",
"@types/react": "^18.0.21", "@types/react": "^18.2.7",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.1",
"@vitejs/plugin-react": "^3.0.1", "@vitejs/plugin-react": "^4.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"typescript": "^4.9.4", "typescript": "^5.0.4",
"vite": "^4.0.4" "vite": "^4.3.8"
} }
} }

View File

@@ -1,45 +1,45 @@
import { ThemeProvider } from "@emotion/react"; import { ThemeProvider } from '@emotion/react'
import {
ChevronLeft, import ChevronLeft from '@mui/icons-material/ChevronLeft'
Dashboard, import Dashboard from '@mui/icons-material/Dashboard'
// Download, import Menu from '@mui/icons-material/Menu'
Menu, Settings as SettingsIcon, import SettingsIcon from '@mui/icons-material/Settings'
FormatListBulleted, import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
SettingsEthernet, import Storage from '@mui/icons-material/Storage'
Storage
} from "@mui/icons-material"; import { Box, createTheme } from '@mui/material'
import {
Box, import CircularProgress from '@mui/material/CircularProgress'
CircularProgress, import CssBaseline from '@mui/material/CssBaseline'
createTheme, CssBaseline, import Divider from '@mui/material/Divider'
Divider, import IconButton from '@mui/material/IconButton'
IconButton, List, import List from '@mui/material/List'
ListItemIcon, ListItemText, Toolbar, import ListItemButton from '@mui/material/ListItemButton'
Typography import ListItemIcon from '@mui/material/ListItemIcon'
} from "@mui/material"; import ListItemText from '@mui/material/ListItemText'
import { grey } from "@mui/material/colors"; import Toolbar from '@mui/material/Toolbar'
import ListItemButton from '@mui/material/ListItemButton'; import Typography from '@mui/material/Typography'
import { lazy, Suspense, useMemo, useState } from "react"; import DownloadIcon from '@mui/icons-material/Download';
import { Provider, useDispatch, useSelector } from "react-redux";
import { import { grey } from '@mui/material/colors'
BrowserRouter as Router, Link, Route,
Routes import { Suspense, lazy, useMemo, useState } from 'react'
} from 'react-router-dom'; import { Provider, useDispatch, useSelector } from 'react-redux'
import { AppBar } from "./components/AppBar";
import { Drawer } from "./components/Drawer"; import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
import { toggleListView } from "./features/settings/settingsSlice"; import { RootState, store } from './stores/store'
import Home from "./Home";
import { RootState, store } from './stores/store'; import AppBar from './components/AppBar'
import { formatGiB, getWebSocketEndpoint } from "./utils"; import Drawer from './components/Drawer'
import Archive from './Archive'
import { formatGiB } from './utils'
function AppContent() { function AppContent() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
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 socket = useMemo(() => new WebSocket(getWebSocketEndpoint()), [])
const mode = settings.theme const mode = settings.theme
const theme = useMemo(() => const theme = useMemo(() =>
@@ -57,11 +57,12 @@ function AppContent() {
setOpen(!open) setOpen(!open)
} }
const Home = lazy(() => import('./Home'))
const Settings = lazy(() => import('./Settings')) const Settings = lazy(() => import('./Settings'))
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}>
@@ -137,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',
@@ -168,18 +176,27 @@ function AppContent() {
> >
<Toolbar /> <Toolbar />
<Routes> <Routes>
<Route path="/" element={<Home socket={socket} />} /> <Route path="/" element={
<Suspense fallback={<CircularProgress />}>
<Home />
</Suspense>
} />
<Route path="/settings" element={ <Route path="/settings" element={
<Suspense fallback={<CircularProgress />}> <Suspense fallback={<CircularProgress />}>
<Settings socket={socket} /> <Settings />
</Suspense>
} />
<Route path="/archive" element={
<Suspense fallback={<CircularProgress />}>
<Archive />
</Suspense> </Suspense>
} /> } />
</Routes> </Routes>
</Box> </Box>
</Box> </Box>
</Router> </BrowserRouter>
</ThemeProvider> </ThemeProvider>
); )
} }
export function App() { export function App() {
@@ -187,5 +204,5 @@ export function App() {
<Provider store={store}> <Provider store={store}>
<AppContent /> <AppContent />
</Provider> </Provider>
); )
} }

221
frontend/src/Archive.tsx Normal file
View File

@@ -0,0 +1,221 @@
import {
Backdrop,
Button,
Checkbox,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
Typography
} from '@mui/material'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import VideoFileIcon from '@mui/icons-material/VideoFile'
import FolderIcon from '@mui/icons-material/Folder'
import { Buffer } from 'buffer'
import { useEffect, useMemo, useState, useTransition } 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 [isPending, startTransition] = useTransition()
const fetcher = () => fetch(`${serverAddr}/downloaded`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subdir: '' })
})
.then(res => res.json())
.then(data => files$.next(data))
const fetcherSubfolder = (sub: string) => {
const folders = sub.split('/')
let subdir = folders.length > 2
? folders.slice(-(folders.length - 1)).join('/')
: folders.pop()
fetch(`${serverAddr}/downloaded`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subdir: subdir })
})
.then(res => res.json())
.then(data => {
files$.next([{
isDirectory: true,
name: '..',
path: '',
}, ...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])
const onFileClick = (path: string) => startTransition(() => {
window.open(`${serverAddr}/play?path=${Buffer.from(path).toString('hex')}`)
})
const onFolderClick = (path: string) => startTransition(() => {
fetcherSubfolder(path)
})
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!(files$.observed) || isPending}
>
<CircularProgress color="primary" />
</Backdrop>
<Paper sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}>
<Typography pb={0} variant="h5" color="primary">
{'Archive'}
</Typography>
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
{selectable.length === 0 && 'No files found'}
{selectable.map((file, idx) => (
<ListItem
key={idx}
secondaryAction={
!file.isDirectory && <Checkbox
edge="end"
checked={file.selected}
onChange={() => addSelected(file.name)}
/>
}
disablePadding
>
<ListItemButton onClick={
() => file.isDirectory
? onFolderClick(file.path)
: onFileClick(file.path)
}>
<ListItemIcon>
{file.isDirectory
? <FolderIcon />
: <VideoFileIcon />
}
</ListItemIcon>
<ListItemText primary={file.name} />
</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>
)
}

View File

@@ -1,8 +1,9 @@
import { FileUpload } from "@mui/icons-material"; import { FileUpload } from '@mui/icons-material'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import { import {
Alert,
Backdrop, Backdrop,
Button, Button,
ButtonGroup,
CircularProgress, CircularProgress,
Container, Container,
FormControl, FormControl,
@@ -14,66 +15,86 @@ import {
Paper, Paper,
Select, Select,
Snackbar, Snackbar,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
styled, styled,
TextField, TextField
Typography } from '@mui/material'
} from "@mui/material"; import { Buffer } from 'buffer'
import { Buffer } from 'buffer'; import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from "react"; import { useDispatch, useSelector } from 'react-redux'
import { useDispatch, useSelector } from "react-redux"; import { DownloadsCardView } from './components/DownloadsCardView'
import { DownloadsCardView } from "./components/DownloadsCardView"; import { DownloadsListView } from './components/DownloadsListView'
import { DownloadsListView } from "./components/DownloadsListView"; 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 } from "./features/core/rpcClient"; import { RPCClient, socket$ } from './features/core/rpcClient'
import { connected, setFreeSpace } from "./features/status/statusSlice"; import { toggleListView } from './features/settings/settingsSlice'
import { RootState } from "./stores/store"; import { connected, setFreeSpace } from './features/status/statusSlice'
import { IDLMetadata, RPCResult } from "./types"; import { RootState } from './stores/store'
import { isValidURL, toFormatArgs } from "./utils"; import type { DLMetadata, RPCResponse, RPCResult } from './types'
import { isValidURL, toFormatArgs } from './utils'
type Props = { export default function Home() {
socket: WebSocket
}
export default function Home({ socket }: Props) {
// redux state // 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 // ephemeral state
const [activeDownloads, setActiveDownloads] = useState<Array<RPCResult>>(); const [activeDownloads, setActiveDownloads] = useState<Array<RPCResult>>()
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>(); const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState(''); const [pickedVideoFormat, setPickedVideoFormat] = useState('')
const [pickedAudioFormat, setPickedAudioFormat] = useState(''); const [pickedAudioFormat, setPickedAudioFormat] = useState('')
const [pickedBestFormat, setPickedBestFormat] = useState(''); const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useState(''); const [customArgs, setCustomArgs] = useState('')
const [downloadPath, setDownloadPath] = useState(0); const [downloadPath, setDownloadPath] = useState(0)
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([]); const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([])
const [fileNameOverride, setFilenameOverride] = useState(''); const [fileNameOverride, setFilenameOverride] = useState('')
const [url, setUrl] = useState(''); const [url, setUrl] = useState('')
const [workingUrl, setWorkingUrl] = useState(''); const [workingUrl, setWorkingUrl] = useState('')
const [showBackdrop, setShowBackdrop] = useState(true); const [showBackdrop, setShowBackdrop] = useState(true)
const [showToast, setShowToast] = useState(true); const [showToast, setShowToast] = useState(true)
const [socketHasError, setSocketHasError] = useState(false)
// memos // memos
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language]) const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(socket), [settings.serverAddr, settings.serverPort]) const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]) const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
// refs
const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null)
/* -------------------- Effects -------------------- */ /* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/ /* WebSocket connect event handler*/
useEffect(() => { useEffect(() => {
socket.onopen = () => { if (!status.connected) {
const sub = socket$.subscribe({
next: () => {
dispatch(connected()) dispatch(connected())
setCustomArgs(localStorage.getItem('last-input-args') ?? '') setCustomArgs(localStorage.getItem('last-input-args') ?? '')
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '') setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
},
error: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
complete: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
})
return () => sub.unsubscribe()
} }
}, []) }, [socket$, status.connected])
useEffect(() => { useEffect(() => {
if (status.connected) { if (status.connected) {
@@ -84,26 +105,27 @@ export default function Home({ socket }: Props) {
}, [status.connected]) }, [status.connected])
useEffect(() => { useEffect(() => {
client.freeSpace() client.freeSpace().then(bytes => dispatch(setFreeSpace(bytes.result)))
.then(bytes => dispatch(setFreeSpace(bytes.result)))
}, []) }, [])
useEffect(() => { useEffect(() => {
socket.onmessage = (event) => { if (status.connected) {
const res = client.decode(event.data) const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
switch (typeof res.result) { switch (typeof event.result) {
case 'object': case 'object':
setActiveDownloads( setActiveDownloads(
(res.result ?? []) (event.result ?? [])
.filter((r: RPCResult) => !!r.info.url) .filter((r) => !!r.info.url)
.sort((a: RPCResult, b: RPCResult) => a.info.title.localeCompare(b.info.title)) .sort((a, b) => a.info.title.localeCompare(b.info.title))
) )
break break
default: default:
break break
} }
})
return () => sub.unsubscribe()
} }
}, []) }, [socket$, status.connected])
useEffect(() => { useEffect(() => {
if (activeDownloads && activeDownloads.length >= 0) { if (activeDownloads && activeDownloads.length >= 0) {
@@ -118,7 +140,7 @@ export default function Home({ socket }: Props) {
}) })
}, []) }, [])
/* -------------------- component functions -------------------- */ /* -------------------- callbacks-------------------- */
/** /**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket * Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
@@ -222,12 +244,9 @@ export default function Home({ socket }: Props) {
} }
const resetInput = () => { const resetInput = () => {
const input = document.getElementById('urlInput') as HTMLInputElement; urlInputRef.current!.value = '';
input.value = ''; if (customFilenameInputRef.current) {
customFilenameInputRef.current!.value = '';
const filename = document.getElementById('customFilenameInput') as HTMLInputElement;
if (filename) {
filename.value = '';
} }
} }
@@ -257,7 +276,7 @@ export default function Home({ socket }: Props) {
<Grid container> <Grid container>
<TextField <TextField
fullWidth fullWidth
id="urlInput" ref={urlInputRef}
label={i18n.t('urlInput')} label={i18n.t('urlInput')}
variant="outlined" variant="outlined"
onChange={handleUrlChange} onChange={handleUrlChange}
@@ -278,10 +297,9 @@ export default function Home({ socket }: Props) {
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 1 }}> <Grid container spacing={1} sx={{ mt: 1 }}>
{ {
settings.enableCustomArgs ? settings.enableCustomArgs &&
<Grid item xs={12}> <Grid item xs={12}>
<TextField <TextField
id="customArgsInput"
fullWidth fullWidth
label={i18n.t('customArgsInput')} label={i18n.t('customArgsInput')}
variant="outlined" variant="outlined"
@@ -289,14 +307,13 @@ export default function Home({ socket }: Props) {
value={customArgs} value={customArgs}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)} disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
/> />
</Grid> : </Grid>
null
} }
{ {
settings.fileRenaming ? settings.fileRenaming &&
<Grid item xs={8}> <Grid item xs={8}>
<TextField <TextField
id="customFilenameInput" ref={customFilenameInputRef}
fullWidth fullWidth
label={i18n.t('customFilename')} label={i18n.t('customFilename')}
variant="outlined" variant="outlined"
@@ -304,11 +321,10 @@ export default function Home({ socket }: Props) {
onChange={handleFilenameOverrideChange} onChange={handleFilenameOverrideChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)} disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
/> />
</Grid> : </Grid>
null
} }
{ {
settings.pathOverriding ? settings.pathOverriding &&
<Grid item xs={4}> <Grid item xs={4}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('customPath')}</InputLabel> <InputLabel>{i18n.t('customPath')}</InputLabel>
@@ -324,8 +340,7 @@ export default function Home({ socket }: Props) {
))} ))}
</Select> </Select>
</FormControl> </FormControl>
</Grid> : </Grid>
null
} }
</Grid> </Grid>
<Grid container spacing={1} pt={2}> <Grid container spacing={1} pt={2}>
@@ -335,7 +350,7 @@ export default function Home({ socket }: Props) {
disabled={url === ''} disabled={url === ''}
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()} onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
> >
{i18n.t('startButton')} {settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')}
</Button> </Button>
</Grid> </Grid>
<Grid item> <Grid item>
@@ -351,117 +366,31 @@ export default function Home({ socket }: Props) {
</Grid> </Grid>
</Grid > </Grid >
{/* Format Selection grid */} {/* Format Selection grid */}
{ {downloadFormats && <FormatsGrid
downloadFormats ? <Grid container spacing={2} mt={2}> downloadFormats={downloadFormats}
<Grid item xs={12}> onBestQualitySelected={(id) => {
<Paper setPickedBestFormat(id)
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<Grid item xs={12}>
<Typography variant="h6" component="div" pb={1}>
{downloadFormats.title}
</Typography>
{/* <Skeleton variant="rectangular" height={180} /> */}
</Grid>
<Grid item xs={12} pb={1}>
<img src={downloadFormats.thumbnail} height={260} width="100%" style={{ objectFit: 'cover' }} />
</Grid>
{/* video only */}
<Grid item xs={12}>
<Typography variant="body1" component="div">
Best quality
</Typography>
</Grid>
<Grid item pr={2} py={1}>
<Button
variant="contained"
disabled={pickedBestFormat !== ''}
onClick={() => {
setPickedBestFormat(downloadFormats.best.format_id)
setPickedVideoFormat('') setPickedVideoFormat('')
setPickedAudioFormat('') setPickedAudioFormat('')
}}> }}
{downloadFormats.best.format_note || downloadFormats.best.format_id} - {downloadFormats.best.vcodec}+{downloadFormats.best.acodec} onVideoSelected={(id) => {
</Button> setPickedVideoFormat(id)
</Grid>
{/* video only */}
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
<Grid item xs={12}>
<Typography variant="body1" component="div">
Video data {downloadFormats.formats[1].acodec}
</Typography>
</Grid>
: null
}
{downloadFormats.formats
.filter(format => format.acodec === 'none' && format.vcodec !== 'none')
.map((format, idx) => (
<Grid item pr={2} py={1} key={idx}>
<Button
variant="contained"
onClick={() => {
setPickedVideoFormat(format.format_id)
setPickedBestFormat('') setPickedBestFormat('')
}} }}
disabled={pickedVideoFormat === format.format_id} onAudioSelected={(id) => {
> setPickedAudioFormat(id)
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
</Button>
</Grid>
))
}
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
<Grid item xs={12}>
<Typography variant="body1" component="div">
Audio data
</Typography>
</Grid>
: null
}
{downloadFormats.formats
.filter(format => format.acodec !== 'none' && format.vcodec === 'none')
.map((format, idx) => (
<Grid item pr={2} py={1} key={idx}>
<Button
variant="contained"
onClick={() => {
setPickedAudioFormat(format.format_id)
setPickedBestFormat('') setPickedBestFormat('')
}} }}
disabled={pickedAudioFormat === format.format_id} onClear={() => {
>
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
</Button>
</Grid>
))
}
<Grid item xs={12} pt={2}>
<ButtonGroup disableElevation variant="contained">
<Button
onClick={() => sendUrl()}
disabled={!pickedBestFormat && !(pickedAudioFormat || pickedVideoFormat)}
> Download
</Button>
<Button
onClick={() => {
setPickedAudioFormat(''); setPickedAudioFormat('');
setPickedVideoFormat(''); setPickedVideoFormat('');
setPickedBestFormat(''); setPickedBestFormat('');
}} }}
> Clear onSubmit={sendUrl}
</Button> pickedBestFormat={pickedBestFormat}
</ButtonGroup> pickedVideoFormat={pickedVideoFormat}
</Grid> pickedAudioFormat={pickedAudioFormat}
</Grid> />}
</Paper>
</Grid>
</Grid> : null
}
{ {
settings.listView ? settings.listView ?
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> : <DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
@@ -470,9 +399,29 @@ export default function Home({ socket }: Props) {
<Snackbar <Snackbar
open={showToast === status.connected} open={showToast === status.connected}
autoHideDuration={1500} autoHideDuration={1500}
message="Connected"
onClose={() => setShowToast(false)} 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
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<FormatListBulleted />}
tooltipTitle={`Table view`}
tooltipOpen
onClick={() => dispatch(toggleListView())}
/> />
</Container > </SpeedDial>
</Container>
); );
} }

View File

@@ -16,15 +16,22 @@ import {
Switch, Switch,
TextField, TextField,
Typography Typography
} from "@mui/material"; } from '@mui/material'
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from 'react-redux'
import { debounceTime, distinctUntilChanged, map, of, takeWhile } from "rxjs"; import {
import { CliArguments } from "./features/core/argsParser"; Subject,
import I18nBuilder from "./features/core/intl"; debounceTime,
import { RPCClient } from "./features/core/rpcClient"; distinctUntilChanged,
map,
takeWhile
} from 'rxjs'
import { CliArguments } from './features/core/argsParser'
import I18nBuilder from './features/core/intl'
import { RPCClient } from './features/core/rpcClient'
import { import {
LanguageUnion, LanguageUnion,
ThemeUnion,
setCliArgs, setCliArgs,
setEnableCustomArgs, setEnableCustomArgs,
setFileRenaming, setFileRenaming,
@@ -33,32 +40,31 @@ import {
setPathOverriding, setPathOverriding,
setServerAddr, setServerAddr,
setServerPort, setServerPort,
setTheme, setTheme
ThemeUnion } from './features/settings/settingsSlice'
} from "./features/settings/settingsSlice"; import { updated } from './features/status/statusSlice'
import { updated } from "./features/status/statusSlice"; import { RootState } from './stores/store'
import { RootState } from "./stores/store"; import { validateDomain, validateIP } from './utils'
import { validateDomain, validateIP } from "./utils";
export default function Settings({ socket }: { socket: WebSocket }) { export default function Settings() {
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch() const dispatch = useDispatch()
const status = useSelector((state: RootState) => state.status)
const settings = useSelector((state: RootState) => state.settings)
const [invalidIP, setInvalidIP] = useState(false); const [invalidIP, setInvalidIP] = useState(false);
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language]) const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(socket), [settings.serverAddr, settings.serverPort])
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]) const client = useMemo(() => new RPCClient(), [])
/** const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
* Update the server ip address state and localstorage whenever the input value changes.
* Validate the ip-addr then set.s const serverAddr$ = useMemo(() => new Subject<string>(), [])
* @param event Input change event const serverPort$ = useMemo(() => new Subject<string>(), [])
*/
const handleAddrChange = (event: any) => { useEffect(() => {
const $serverAddr = of(event) const sub = serverAddr$
.pipe( .pipe(
map(event => event.target.value),
debounceTime(500), debounceTime(500),
distinctUntilChanged() distinctUntilChanged()
) )
@@ -73,24 +79,21 @@ export default function Settings({ socket }: { socket: WebSocket }) {
setInvalidIP(true) setInvalidIP(true)
} }
}) })
return $serverAddr.unsubscribe() return () => sub.unsubscribe()
} }, [serverAddr$])
/** useEffect(() => {
* Set server port const sub = serverPort$
*/
const handlePortChange = (event: any) => {
const $port = of(event)
.pipe( .pipe(
map(event => event.target.value), debounceTime(500),
map(val => Number(val)), map(val => Number(val)),
takeWhile(val => isFinite(val) && val <= 65535), takeWhile(val => isFinite(val) && val <= 65535),
) )
.subscribe(port => { .subscribe(port => {
dispatch(setServerPort(port.toString())) dispatch(setServerPort(port.toString()))
}) })
return $port.unsubscribe() return () => sub.unsubscribe()
} }, [])
/** /**
* Language toggler handler * Language toggler handler
@@ -107,7 +110,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
} }
/** /**
* Send via WebSocket a message in order to update the yt-dlp binary from server * Send via WebSocket a message to update yt-dlp binary
*/ */
const updateBinary = () => { const updateBinary = () => {
client.updateExecutable().then(() => dispatch(updated())) client.updateExecutable().then(() => dispatch(updated()))
@@ -125,7 +128,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
minHeight: 240, minHeight: 240,
}} }}
> >
<Typography pb={2} variant="h6" color="primary"> <Typography pb={3} variant="h5" color="primary">
{i18n.t('settingsAnchor')} {i18n.t('settingsAnchor')}
</Typography> </Typography>
<FormGroup> <FormGroup>
@@ -136,7 +139,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
label={i18n.t('serverAddressTitle')} label={i18n.t('serverAddressTitle')}
defaultValue={settings.serverAddr} defaultValue={settings.serverAddr}
error={invalidIP} error={invalidIP}
onChange={handleAddrChange} onChange={(e) => serverAddr$.next(e.currentTarget.value)}
InputProps={{ InputProps={{
startAdornment: <InputAdornment position="start">ws://</InputAdornment>, startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
}} }}
@@ -148,7 +151,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
fullWidth fullWidth
label={i18n.t('serverPortTitle')} label={i18n.t('serverPortTitle')}
defaultValue={settings.serverPort} defaultValue={settings.serverPort}
onChange={handlePortChange} onChange={(e) => serverPort$.next(e.currentTarget.value)}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535} error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> />
@@ -186,16 +189,6 @@ export default function Settings({ socket }: { socket: WebSocket }) {
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
{/* <Grid item xs={12} md={6}>
<TextField
fullWidth
label={'Max download speed' || i18n.t('serverPortTitle')}
defaultValue={settings.serverPort}
onChange={handlePortChange}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
sx={{ mb: 2 }}
/>
</Grid> */}
</Grid> </Grid>
<FormControlLabel <FormControlLabel
control={ control={

View File

@@ -4,6 +4,7 @@ languages:
urlInput: YouTube or other supported service video URL urlInput: YouTube or other supported service video URL
statusTitle: Status statusTitle: Status
statusReady: Ready statusReady: Ready
selectFormatButton: Select format
startButton: Start startButton: Start
abortAllButton: Abort All abortAllButton: Abort All
updateBinButton: Update yt-dlp binary updateBinButton: Update yt-dlp binary
@@ -27,6 +28,7 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server
italian: italian:
urlInput: URL di YouTube o di qualsiasi altro servizio supportato urlInput: URL di YouTube o di qualsiasi altro servizio supportato
statusTitle: Stato statusTitle: Stato
@@ -54,6 +56,7 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error nella connessione al server RPC
chinese: chinese:
urlInput: YouTube 或其他受支持服务的视频网址 urlInput: YouTube 或其他受支持服务的视频网址
statusTitle: 状态 statusTitle: 状态
@@ -81,6 +84,7 @@ languages:
customPath: 自定义路径 customPath: 自定义路径
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大) customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
customArgsInput: 自定义 yt-dlp 参数 customArgsInput: 自定义 yt-dlp 参数
rpcConnErr: Error while conencting to RPC server
spanish: spanish:
urlInput: YouTube or other supported service video url urlInput: YouTube or other supported service video url
statusTitle: Status statusTitle: Status
@@ -108,6 +112,7 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server
russian: russian:
urlInput: YouTube or other supported service video url urlInput: YouTube or other supported service video url
statusTitle: Status statusTitle: Status
@@ -135,6 +140,7 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -162,10 +168,12 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
statusReady: 準備 statusReady: 準備
selectFormatButton: フォーマット選択
startButton: 開始 startButton: 開始
abortAllButton: すべて中止 abortAllButton: すべて中止
updateBinButton: yt-dlp更新 updateBinButton: yt-dlp更新
@@ -180,12 +188,13 @@ languages:
toastConnected: '接続中 ' toastConnected: '接続中 '
toastUpdated: yt-dlpを更新しました! toastUpdated: yt-dlpを更新しました!
formatSelectionEnabler: 選択可能な動画/音源 formatSelectionEnabler: 選択可能な動画/音源
themeSelect: 'Theme' themeSelect: 'テーマ'
languageSelect: 'Language' languageSelect: '言語'
overridesAnchor: Overrides overridesAnchor: 上書き
pathOverrideOption: Enable output path overriding pathOverrideOption: 保存するディレクトリ
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: ファイル名の上書き
customFilename: Custom filemame (leave blank to use default) customFilename: (空白の場合は元のファイル名)
customPath: Custom path customPath: 保存先
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
customArgsInput: Custom yt-dlp arguments customArgsInput: yt-dlpのオプション
rpcConnErr: Error while conencting to RPC server

View File

@@ -7,7 +7,7 @@ interface AppBarProps extends MuiAppBarProps {
const drawerWidth = 240; const drawerWidth = 240;
export const AppBar = styled(MuiAppBar, { const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== 'open', shouldForwardProp: (prop) => prop !== 'open',
})<AppBarProps>(({ theme, open }) => ({ })<AppBarProps>(({ theme, open }) => ({
zIndex: theme.zIndex.drawer + 1, zIndex: theme.zIndex.drawer + 1,
@@ -24,3 +24,5 @@ export const AppBar = styled(MuiAppBar, {
}), }),
}), }),
})); }));
export default AppBar

View File

@@ -1,5 +1,12 @@
import { Card, CardActionArea, CardContent, CardMedia, Skeleton, Typography } from "@mui/material"; import {
import { ellipsis } from "../utils"; Card,
CardActionArea,
CardContent,
CardMedia,
Skeleton,
Typography
} from '@mui/material'
import { ellipsis } from '../utils'
type Props = { type Props = {
title: string, title: string,

View File

@@ -1,11 +1,12 @@
import { Grid } from "@mui/material" import { Grid } from "@mui/material"
import { Fragment } from "react" import { Fragment } from "react"
import type { RPCResult } from "../types" import type { RPCResult } from "../types"
import { StackableResult } from "./StackableResult" 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) {

View File

@@ -1,6 +1,18 @@
import { Button, CircularProgress, Grid, LinearProgress, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material" import {
import { RPCResult } from "../types" Button,
Grid,
LinearProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from "@mui/material"
import { ellipsis, formatSpeedMiB, roundMiB } from "../utils" import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
import type { RPCResult } from "../types"
type Props = { type Props = {
downloads: RPCResult[] downloads: RPCResult[]

View File

@@ -1,9 +1,9 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material'
import MuiDrawer from '@mui/material/Drawer'; import MuiDrawer from '@mui/material/Drawer'
const drawerWidth = 240; const drawerWidth = 240
export const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })( const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })(
({ theme, open }) => ({ ({ theme, open }) => ({
'& .MuiDrawer-paper': { '& .MuiDrawer-paper': {
position: 'relative', position: 'relative',
@@ -27,4 +27,6 @@ export const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !==
}), }),
}, },
}), }),
); )
export default Drawer

View File

@@ -0,0 +1,126 @@
import { Button, ButtonGroup, Grid, Paper, Typography } from "@mui/material"
import type { DLMetadata } from '../types'
type Props = {
downloadFormats: DLMetadata
onAudioSelected: (format: string) => void
onVideoSelected: (format: string) => void
onBestQualitySelected: (format: string) => void
onSubmit: () => void
onClear: () => void
pickedBestFormat: string
pickedAudioFormat: string
pickedVideoFormat: string
}
export default function FormatsGrid({
downloadFormats,
onAudioSelected,
onVideoSelected,
onBestQualitySelected,
onSubmit,
onClear,
pickedBestFormat,
pickedAudioFormat,
pickedVideoFormat,
}: Props) {
return (
<Grid container spacing={2} mt={2}>
<Grid item xs={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<Grid item xs={12}>
<Typography variant="h6" component="div" pb={1}>
{downloadFormats.title}
</Typography>
{/* <Skeleton variant="rectangular" height={180} /> */}
</Grid>
<Grid item xs={12} pb={1}>
<img src={downloadFormats.thumbnail} height={260} width="100%" style={{ objectFit: 'cover' }} />
</Grid>
{/* video only */}
<Grid item xs={12}>
<Typography variant="body1" component="div">
Best quality
</Typography>
</Grid>
<Grid item pr={2} py={1}>
<Button
variant="contained"
disabled={pickedBestFormat !== ''}
onClick={() => onBestQualitySelected(downloadFormats.best.format_id)}
>
{downloadFormats.best.format_note || downloadFormats.best.format_id} - {downloadFormats.best.vcodec}+{downloadFormats.best.acodec}
&nbsp;({downloadFormats.best.resolution}{(downloadFormats.best.filesize_approx > 0) ? ", ~" + Math.round(downloadFormats.best.filesize_approx / 1024 / 1024) + " MiB" : ""})
</Button>
</Grid>
{/* video only */}
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length &&
<Grid item xs={12}>
<Typography variant="body1" component="div">
Video data {downloadFormats.formats[1].acodec}
</Typography>
</Grid>
}
{downloadFormats.formats
.filter(format => format.acodec === 'none' && format.vcodec !== 'none')
.map((format, idx) => (
<Grid item pr={2} py={1} key={idx}>
<Button
variant="contained"
onClick={() => onVideoSelected(format.format_id)}
disabled={pickedVideoFormat === format.format_id}
>
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
&nbsp;({format.resolution}{(format.filesize_approx > 0) ? ", ~" + Math.round(format.filesize_approx / 1024 / 1024) + " MiB" : ""})
</Button>
</Grid>
))
}
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length &&
<Grid item xs={12}>
<Typography variant="body1" component="div">
Audio data
</Typography>
</Grid>
}
{downloadFormats.formats
.filter(format => format.acodec !== 'none' && format.vcodec === 'none')
.map((format, idx) => (
<Grid item pr={2} py={1} key={idx}>
<Button
variant="contained"
onClick={() => onAudioSelected(format.format_id)}
disabled={pickedAudioFormat === format.format_id}
>
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
{(format.filesize_approx > 0) ? " (~" + Math.round(format.filesize_approx / 1024 / 1024) + " MiB)" : ""}
</Button>
</Grid>
))
}
<Grid item xs={12} pt={2}>
<ButtonGroup disableElevation variant="contained">
<Button
onClick={() => onSubmit()}
disabled={!pickedBestFormat && !(pickedAudioFormat || pickedVideoFormat)}
> Download
</Button>
<Button
onClick={() => onClear()}
> Clear
</Button>
</ButtonGroup>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
)
}

View File

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

View File

@@ -1,13 +1,14 @@
import type { RPCRequest, RPCResponse, IDLMetadata } from "../../types" import type { DLMetadata, RPCRequest, RPCResponse } from '../../types'
import { getHttpRPCEndpoint } from '../../utils' import { webSocket } from 'rxjs/webSocket'
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../../utils'
export const socket$ = webSocket<any>(getWebSocketEndpoint())
export class RPCClient { export class RPCClient {
private socket: WebSocket
private seq: number private seq: number
constructor(socket: WebSocket) { constructor() {
this.socket = socket
this.seq = 0 this.seq = 0
} }
@@ -16,27 +17,28 @@ export class RPCClient {
} }
private send(req: RPCRequest) { private send(req: RPCRequest) {
this.socket.send(JSON.stringify(req)) socket$.next({
...req,
id: this.incrementSeq(),
})
} }
private sendHTTP<T>(req: RPCRequest) { private async sendHTTP<T>(req: RPCRequest) {
return new Promise<RPCResponse<T>>((resolve) => { const res = await fetch(getHttpRPCEndpoint(), {
fetch(getHttpRPCEndpoint(), {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
...req,
id: this.incrementSeq(), id: this.incrementSeq(),
...req
}) })
}) })
.then(res => res.json()) const data: RPCResponse<T> = await res.json()
.then(data => resolve(data))
}) return data
} }
public download(url: string, args: string, pathOverride = '', renameTo = '') { public download(url: string, args: string, pathOverride = '', renameTo = '') {
if (url) { if (url) {
this.send({ this.send({
id: this.incrementSeq(),
method: 'Service.Exec', method: 'Service.Exec',
params: [{ params: [{
URL: url.split("?list").at(0)!, URL: url.split("?list").at(0)!,
@@ -50,8 +52,7 @@ export class RPCClient {
public formats(url: string) { public formats(url: string) {
if (url) { if (url) {
return this.sendHTTP<IDLMetadata>({ return this.sendHTTP<DLMetadata>({
id: this.incrementSeq(),
method: 'Service.Formats', method: 'Service.Formats',
params: [{ params: [{
URL: url.split("?list").at(0)!, URL: url.split("?list").at(0)!,
@@ -62,7 +63,6 @@ export class RPCClient {
public running() { public running() {
this.send({ this.send({
id: this.incrementSeq(),
method: 'Service.Running', method: 'Service.Running',
params: [], params: [],
}) })
@@ -102,8 +102,4 @@ export class RPCClient {
params: [] params: []
}) })
} }
public decode(data: any): RPCResponse<any> {
return JSON.parse(data)
}
} }

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { Observable } from 'rxjs'
/**
* Handles the subscription and unsubscription from an observable.
* Automatically disposes the subscription.
* @param source$ source observable
* @param nextHandler subscriber function
* @param errHandler error catching callback
*/
export function useSubscription<T>(
source$: Observable<T>,
nextHandler: (value: T) => void,
errHandler?: (err: any) => void,
) {
useEffect(() => {
if (source$) {
const sub = source$.subscribe({
next: nextHandler,
error: errHandler,
})
return () => sub.unsubscribe()
}
}, [source$])
}
/**
* Use an observable as state
* @param source$ source observable
* @param initialState the initial state prior to the emission
* @param errHandler error catching callback
* @returns value emitted to the observable
*/
export function useObservable<T>(
source$: Observable<T>,
initialState: T,
errHandler?: (err: any) => void,
): T {
const [value, setValue] = useState(initialState)
useSubscription(source$, setValue, errHandler)
return value
}

View File

@@ -1,10 +1,10 @@
import React from 'react' import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { App } from './App' import { App } from './App'
const root = ReactDOM.createRoot(document.getElementById('root')!) const root = createRoot(document.getElementById('root')!)
root.render( root.render(
<React.StrictMode> <StrictMode>
<App></App> <App />
</React.StrictMode> </StrictMode>
) )

View File

@@ -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
} }
@@ -45,18 +45,31 @@ export type RPCParams = {
Params?: string Params?: string
} }
export interface IDLMetadata { export interface DLMetadata {
formats: Array<IDLFormat>, formats: Array<DLFormat>
best: IDLFormat, best: DLFormat
thumbnail: string, thumbnail: string
title: string, title: string
} }
export interface IDLFormat { 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
} }
export type DirectoryEntry = {
name: string
path: string
shaSum: string
isDirectory: boolean
}
export type DeleteRequest = Omit<DirectoryEntry, 'name' | 'isDirectory'>
export type PlayRequest = Omit<DirectoryEntry, 'shaSum' | 'name' | 'isDirectory'>

View File

@@ -74,13 +74,18 @@ export function toFormatArgs(codes: string[]): string {
} }
export function getWebSocketEndpoint() { export function getWebSocketEndpoint() {
return `ws://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/ws-rpc` const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/ws-rpc`
} }
export function getHttpRPCEndpoint() { 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`
} }

27
go.mod
View File

@@ -3,25 +3,28 @@ module github.com/marcopeocchi/yt-dlp-web-ui
go 1.19 go 1.19
require ( require (
github.com/goccy/go-json v0.10.0 github.com/goccy/go-json v0.10.2
github.com/gofiber/fiber/v2 v2.41.0 github.com/gofiber/fiber/v2 v2.43.0
github.com/gofiber/websocket/v2 v2.1.2 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-20221114144444-1e802380a7db github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
golang.org/x/sys v0.4.0 golang.org/x/sys v0.7.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/andybalholm/brotli v1.0.4 // indirect github.com/andybalholm/brotli v1.0.5 // indirect
github.com/fasthttp/websocket v1.5.0 // indirect github.com/fasthttp/websocket v1.5.2 // indirect
github.com/klauspost/compress v1.15.14 // indirect github.com/klauspost/compress v1.16.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/rivo/uniseg v0.4.3 // indirect github.com/philhofer/fwd v1.1.2 // indirect
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d // indirect github.com/rivo/uniseg v0.4.4 // indirect
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.43.0 // indirect github.com/valyala/fasthttp v1.45.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
) )

107
go.sum
View File

@@ -1,61 +1,92 @@
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/fasthttp/websocket v1.5.0 h1:B4zbe3xXyvIdnqjOZrafVFklCUq5ZLo/TqCt5JA1wLE= github.com/fasthttp/websocket v1.5.2 h1:KdCb0EpLpdJpfE3IPA5YLK/aYBO3dhZcvwxz6tXe2LQ=
github.com/fasthttp/websocket v1.5.0/go.mod h1:n0BlOQvJdPbTuBkZT0O5+jk/sp/1/VCzquR1BehI2F4= github.com/fasthttp/websocket v1.5.2/go.mod h1:S0KC1VBlx1SaXGXq7yi1wKz4jMub58qEnHQG9oHuqBw=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofiber/fiber/v2 v2.40.1/go.mod h1:Gko04sLksnHbzLSRBFWPFdzM9Ws9pRxvvIaohJK1dsk= github.com/gofiber/fiber/v2 v2.43.0 h1:yit3E4kHf178B60p5CQBa/3v+WVuziWMa/G2ZNyLJB0=
github.com/gofiber/fiber/v2 v2.41.0 h1:YhNoUS/OTjEz+/WLYuQ01xI7RXgKEFnGBKMagAu5f0M= github.com/gofiber/fiber/v2 v2.43.0/go.mod h1:mpS1ZNE5jU+u+BA4FbM+KKnUzJ4wzTK+FT2tG3tU+6I=
github.com/gofiber/fiber/v2 v2.41.0/go.mod h1:RdebcCuCRFp4W6hr3968/XxwJVg0K+jr9/Ae0PFzZ0Q= github.com/gofiber/websocket/v2 v2.1.5 h1:2weAMr0Shb2ubhZ3+P4bkeWL+uCZ/NlgjSa1siEcvFM=
github.com/gofiber/websocket/v2 v2.1.2 h1:EulKyLB/fJgui5+6c8irwEnYQ9FRsrLZfkrq9OfTDGc= github.com/gofiber/websocket/v2 v2.1.5/go.mod h1:BZZEk+XsjjF0V6/sAw00iGcB69dFb6Hb85ER9gr/xaU=
github.com/gofiber/websocket/v2 v2.1.2/go.mod h1:S+sKWo0xeC7Wnz5h4/8f6D/NxsrLFIdWDYB3SyVO9pE=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.14.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.16.4 h1:91KN02FnsOYhuunwU4ssRe8lc2JosWmizWa91B5v1PU=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.16.4/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
github.com/klauspost/compress v1.15.14/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
github.com/marcopeocchi/fazzoletti v0.0.0-20221114144444-1e802380a7db h1:SmKRgCLsImPxBTIzmUpbQyv+7FembiZaq/QTwtDqar4=
github.com/marcopeocchi/fazzoletti v0.0.0-20221114144444-1e802380a7db/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas= github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d h1:Q+gqLBOPkFGHyCJxXMRqtUgUbTjI8/Ze8vu8GGyNFwo= github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.33.0/go.mod h1:KJRK/MXx0J+yd0c5hlR+s1tIHD72sniU8ZJjl97LIw4= github.com/valyala/fasthttp v1.45.0 h1:zPkkzpIn8tdHZUrVa6PzYd0i5verqiPSkgTd3bSUcpA=
github.com/valyala/fasthttp v1.41.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g=
github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
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=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"embed" "embed"
"flag" "flag"
"io/fs" "io/fs"
@@ -11,8 +10,6 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
type ContextKey interface{}
var ( var (
port int port int
downloadPath string downloadPath string
@@ -48,9 +45,5 @@ func main() {
cfg.DownloadPath(downloadPath) cfg.DownloadPath(downloadPath)
cfg.DownloaderPath(downloaderPath) cfg.DownloaderPath(downloaderPath)
ctx := context.Background() server.RunBlocking(port, frontend)
ctx = context.WithValue(ctx, ContextKey("port"), port)
ctx = context.WithValue(ctx, ContextKey("frontend"), frontend)
server.RunBlocking(ctx)
} }

View File

@@ -1,6 +1,8 @@
package server package server
import ( import (
"errors"
"fmt"
"log" "log"
"os" "os"
"sync" "sync"
@@ -11,88 +13,76 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
) )
// In-Memory volatile Thread-Safe Key-Value Storage // In-Memory Thread-Safe Key-Value Storage with optional persistence
type MemoryDB struct { type MemoryDB struct {
table map[string]*Process table sync.Map
mu sync.Mutex
}
// Inits the db with an empty map of string->Process pointer
func (m *MemoryDB) New() {
m.table = make(map[string]*Process)
} }
// Get a process pointer given its id // Get a process pointer given its id
func (m *MemoryDB) Get(id string) *Process { func (m *MemoryDB) Get(id string) (*Process, error) {
m.mu.Lock() entry, ok := db.table.Load(id)
res := m.table[id] if !ok {
m.mu.Unlock() return nil, errors.New("no process found for the given key")
return res }
return entry.(*Process), nil
} }
// Store a pointer of a process and return its id // Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string { func (m *MemoryDB) Set(process *Process) string {
id := uuid.Must(uuid.NewRandom()).String() id := uuid.Must(uuid.NewRandom()).String()
m.mu.Lock() db.table.Store(id, process)
m.table[id] = process
m.mu.Unlock()
return id return id
} }
// Update a process info/metadata, given the process id // Update a process info/metadata, given the process id
func (m *MemoryDB) Update(id string, info DownloadInfo) { func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error {
m.mu.Lock() entry, ok := db.table.Load(id)
if m.table[id] != nil { if ok {
m.table[id].Info = info entry.(*Process).Info = info
db.table.Store(id, entry)
return nil
} }
m.mu.Unlock() return fmt.Errorf("can't update row with id %s", id)
} }
// Update a process progress data, given the process id // Update a process progress data, given the process id
// Used for updating completition percentage or ETA // Used for updating completition percentage or ETA
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) { func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
m.mu.Lock() entry, ok := db.table.Load(id)
if m.table[id] != nil { if ok {
m.table[id].Progress = progress entry.(*Process).Progress = progress
db.table.Store(id, entry)
return nil
} }
m.mu.Unlock() return fmt.Errorf("can't update row with id %s", id)
} }
// Removes a process progress, given the process id // Removes a process progress, given the process id
func (m *MemoryDB) Delete(id string) { func (m *MemoryDB) Delete(id string) {
m.mu.Lock() db.table.Delete(id)
delete(m.table, id)
m.mu.Unlock()
} }
// Returns a slice of all currently stored processes id func (m *MemoryDB) Keys() *[]string {
func (m *MemoryDB) Keys() []string { running := []string{}
m.mu.Lock() db.table.Range(func(key, value any) bool {
keys := make([]string, len(m.table)) running = append(running, key.(string))
i := 0 return true
for k := range m.table { })
keys[i] = k return &running
i++
}
m.mu.Unlock()
return keys
} }
// Returns a slice of all currently stored processes progess // Returns a slice of all currently stored processes progess
func (m *MemoryDB) All() []ProcessResponse { func (m *MemoryDB) All() *[]ProcessResponse {
running := make([]ProcessResponse, len(m.table)) running := []ProcessResponse{}
i := 0 db.table.Range(func(key, value any) bool {
for k, v := range m.table { running = append(running, ProcessResponse{
if v != nil { Id: key.(string),
running[i] = ProcessResponse{ Info: value.(*Process).Info,
Id: k, Progress: value.(*Process).Progress,
Info: v.Info, })
Progress: v.Progress, return true
} })
i++ return &running
}
}
return running
} }
// WIP: Persist the database in a single file named "session.dat" // WIP: Persist the database in a single file named "session.dat"
@@ -100,7 +90,7 @@ func (m *MemoryDB) Persist() {
running := m.All() running := m.All()
session, err := json.Marshal(Session{ session, err := json.Marshal(Session{
Processes: running, Processes: *running,
}) })
if err != nil { if err != nil {
log.Println(cli.Red, "Failed to persist database", cli.Reset) log.Println(cli.Red, "Failed to persist database", cli.Reset)
@@ -118,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,
})
}
} }

View File

@@ -118,7 +118,7 @@ func (p *Process) Start(path, filename string) {
} }
info := DownloadInfo{URL: p.url} info := DownloadInfo{URL: p.url}
json.Unmarshal(stdout, &info) json.Unmarshal(stdout, &info)
p.mem.Update(p.id, info) p.mem.UpdateInfo(p.id, info)
}() }()
// --------------- progress block --------------- // // --------------- progress block --------------- //
@@ -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

135
server/rest/handlers.go Normal file
View File

@@ -0,0 +1,135 @@
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"
)
type DirectoryEntry struct {
Name string `json:"name"`
Path string `json:"path"`
SHASum string `json:"shaSum"`
IsDirectory bool `json:"isDirectory"`
}
func isValidEntry(d fs.DirEntry) bool {
return !strings.HasPrefix(d.Name(), ".") &&
!strings.HasSuffix(d.Name(), ".part") &&
!strings.HasSuffix(d.Name(), ".ytdl")
}
func shaSumString(path string) string {
h := sha256.New()
h.Write([]byte(path))
return hex.EncodeToString(h.Sum(nil))
}
func walkDir(root string) (*[]DirectoryEntry, error) {
files := []DirectoryEntry{}
dirs, err := os.ReadDir(root)
if err != nil {
return nil, err
}
for _, d := range dirs {
if !isValidEntry(d) {
continue
}
path := filepath.Join(root, d.Name())
files = append(files, DirectoryEntry{
Path: path,
Name: d.Name(),
SHASum: shaSumString(path),
IsDirectory: d.IsDir(),
})
}
return &files, err
}
type ListRequest struct {
SubDir string `json:"subdir"`
}
func ListDownloaded(ctx *fiber.Ctx) error {
root := config.Instance().GetConfig().DownloadPath
req := new(ListRequest)
err := ctx.BodyParser(req)
if err != nil {
return err
}
files, err := walkDir(filepath.Join(root, req.SubDir))
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
}
sum := shaSumString(req.Path)
if sum != req.SHASum {
return errors.New("shasum mismatch")
}
err = os.Remove(req.Path)
if err != nil {
return err
}
ctx.Status(fiber.StatusOK)
return ctx.JSON("ok")
}
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)
}

View File

@@ -11,6 +11,7 @@ import "time"
// //
// Debounce emits a string from the source channel only after a particular // Debounce emits a string from the source channel only after a particular
// time span determined a Go Interval // time span determined a Go Interval
//
// --A--B--CD--EFG-------|> // --A--B--CD--EFG-------|>
// //
// -t-> |> // -t-> |>
@@ -18,7 +19,7 @@ import "time"
// -t-> |> // -t-> |>
// //
// --A-----C-----G-------|> // --A-----C-----G-------|>
func Debounce(interval time.Duration, source chan string, cb func(emit string)) { func Debounce(interval time.Duration, source chan string, f func(emit string)) {
var item string var item string
timer := time.NewTimer(interval) timer := time.NewTimer(interval)
for { for {
@@ -27,7 +28,7 @@ func Debounce(interval time.Duration, source chan string, cb func(emit string))
timer.Reset(interval) timer.Reset(interval)
case <-timer.C: case <-timer.C:
if item != "" { if item != "" {
cb(item) f(item)
} }
} }
} }

View File

@@ -8,22 +8,22 @@ import (
"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 init() { func RunBlocking(port int, frontend fs.FS) {
db.New() db.Restore()
}
func RunBlocking(ctx context.Context) {
fe := ctx.Value("frontend").(fs.SubFS)
port := ctx.Value("port").(int)
service := new(Service) service := new(Service)
rpc.Register(service) rpc.Register(service)
@@ -32,12 +32,28 @@ func RunBlocking(ctx context.Context) {
app.Use(cors.New()) app.Use(cors.New())
app.Use("/", filesystem.New(filesystem.Config{ app.Use("/", filesystem.New(filesystem.Config{
Root: http.FS(fe), Root: http.FS(frontend),
})) }))
app.Get("/settings", func(c *fiber.Ctx) error {
return c.Redirect("/")
})
app.Get("/archive", func(c *fiber.Ctx) error {
return c.Redirect("/")
})
app.Post("/downloaded", rest.ListDownloaded)
app.Post("/delete", rest.DeleteFile)
app.Get("/play", rest.PlayFile)
// RPC handlers // RPC handlers
// websocket // websocket
app.Get("/ws-rpc", websocket.New(func(c *websocket.Conn) { app.Get("/ws-rpc", websocket.New(func(c *websocket.Conn) {
c.WriteMessage(websocket.TextMessage, []byte(`{
"status": "connected"
}`))
for { for {
mtype, reader, err := c.NextReader() mtype, reader, err := c.NextReader()
if err != nil { if err != nil {
@@ -56,12 +72,43 @@ func RunBlocking(ctx context.Context) {
app.Post("/http-rpc", func(c *fiber.Ctx) error { app.Post("/http-rpc", func(c *fiber.Ctx) error {
reader := c.Context().RequestBodyStream() reader := c.Context().RequestBodyStream()
writer := c.Response().BodyWriter() writer := c.Response().BodyWriter()
res := NewRPCRequest(reader).Call() res := NewRPCRequest(reader).Call()
io.Copy(writer, res) io.Copy(writer, res)
return nil return nil
}) })
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)
}
}

View File

@@ -40,7 +40,11 @@ func (t *Service) Exec(args DownloadSpecificArgs, result *string) error {
// Progess retrieves the Progress of a specific Process given its Id // Progess retrieves the Progress of a specific Process given its Id
func (t *Service) Progess(args Args, progress *DownloadProgress) error { func (t *Service) Progess(args Args, progress *DownloadProgress) error {
*progress = db.Get(args.Id).Progress proc, err := db.Get(args.Id)
if err != nil {
return err
}
*progress = proc.Progress
return nil return nil
} }
@@ -54,24 +58,29 @@ func (t *Service) Formats(args Args, progress *DownloadFormats) error {
// Pending retrieves a slice of all Pending/Running processes ids // Pending retrieves a slice of all Pending/Running processes ids
func (t *Service) Pending(args NoArgs, pending *Pending) error { func (t *Service) Pending(args NoArgs, pending *Pending) error {
*pending = Pending(db.Keys()) *pending = *db.Keys()
return nil return nil
} }
// Running retrieves a slice of all Processes progress // Running retrieves a slice of all Processes progress
func (t *Service) Running(args NoArgs, running *Running) error { func (t *Service) Running(args NoArgs, running *Running) error {
*running = db.All() *running = *db.All()
return nil return nil
} }
// Kill kills a process given its id and remove it from the memoryDB // Kill kills a process given its id and remove it from the memoryDB
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 := db.Get(args) proc, err := db.Get(args)
var err error
if err != nil {
return err
}
if proc != nil { if proc != nil {
err = proc.Kill() err = proc.Kill()
} }
db.Delete(proc.id)
return err return err
} }
@@ -81,8 +90,11 @@ func (t *Service) KillAll(args NoArgs, killed *string) error {
log.Println("Killing all spawned processes", args) log.Println("Killing all spawned processes", args)
keys := db.Keys() keys := db.Keys()
var err error var err error
for _, key := range keys { for _, key := range *keys {
proc := db.Get(key) proc, err := db.Get(key)
if err != nil {
return err
}
if proc != nil { if proc != nil {
proc.Kill() proc.Kill()
} }

View File

@@ -36,6 +36,7 @@ type Format struct {
Resolution string `json:"resolution"` Resolution string `json:"resolution"`
VCodec string `json:"vcodec"` VCodec string `json:"vcodec"`
ACodec string `json:"acodec"` ACodec string `json:"acodec"`
Size float32 `json:"filesize_approx"`
} }
// struct representing the response sent to the client // struct representing the response sent to the client