Compare commits

...

36 Commits

Author SHA1 Message Date
Marco
3859c80214 code refactoring, added file download (#118) 2023-12-27 15:08:51 +01:00
Marco
c5535fad71 jwt in headers+localstorage instead of httpOnly cookie (#117) 2023-12-27 14:32:08 +01:00
Marco
f7ba203ed0 deprecating docker armv7 builds 2023-12-06 13:22:06 +01:00
3ba8c455df fix archive files list 2023-12-03 13:33:03 +01:00
8c166147b0 code refactor 2023-12-03 12:12:19 +01:00
70a8d27d22 fixed redirect when auth is enabled 2023-12-03 11:53:15 +01:00
0ab9f15184 updated deps, new cors rules, code refactor 2023-12-03 11:13:37 +01:00
phuslu
1636191f0d support bind address, fix https://github.com/marcopeocchi/yt-dlp-web-ui/issues/108 (#109) 2023-11-27 08:41:33 +01:00
Marco
f478754b6f fixed double ext and playlist title detection (#106) 2023-11-22 11:14:58 +01:00
fe6519773e typescript bumped to 5.3.2 2023-11-21 18:07:18 +01:00
17779995c3 archive bugfix 2023-11-21 18:07:04 +01:00
12f6b6bf10 code refactoring 2023-11-21 13:43:59 +01:00
3f1f67b2c6 fix 'Don't set file modification time' behavior
closes #103
2023-11-21 13:43:51 +01:00
ec3a5ad1ee code refactoring, removed react helmet 2023-11-21 13:12:41 +01:00
c56e3a106b code refactoring 2023-11-19 13:52:51 +01:00
Michael M. Chang
710a8537e0 better reverse proxy detection (#101) 2023-11-04 15:00:28 +01:00
Marco
a52225323c Update archive.go 2023-11-02 11:19:07 +01:00
1d9dabd397 frontend performance optimizations 2023-11-02 10:42:59 +01:00
f49f072963 code refactoring 2023-11-02 10:18:32 +01:00
00b3fccbdc optimizations, added captions 2023-11-02 10:05:34 +01:00
19f9b10844 templates editor #62 2023-10-30 11:48:10 +01:00
cd5c210eac code refactoring 2023-10-29 15:07:42 +01:00
252d2f2845 fixed potential race condition 2023-10-28 12:10:50 +02:00
c320058af3 bug fix 2023-10-28 10:38:26 +02:00
062e07aa98 code refactoring, backend code optimization 2023-10-28 10:32:28 +02:00
2564751405 small fixes 2023-10-26 17:53:39 +02:00
ce838b8fb0 Dockerfile refactor 2023-10-25 14:08:00 +02:00
1aadce8dd7 Dockerfile refactor 2023-10-25 14:04:02 +02:00
d5695077c4 Dockerfile refactor 2023-10-25 13:57:55 +02:00
2c238957d0 Dockerfile refactor 2023-10-25 13:55:39 +02:00
d47b8496a7 added example nginx and caddy configs 2023-10-25 13:46:13 +02:00
2f8b4cd1d6 config package code refactor 2023-10-24 16:18:07 +02:00
b512e996ad config package refactor 2023-10-24 16:07:38 +02:00
0c7415df28 code refactoring 2023-10-24 15:29:20 +02:00
38d8bb8e40 code refactoring 2023-10-24 14:45:55 +02:00
Marco
ba23485b33 97 custom arguments broken (#99)
* golang debug

* handle template in playlist download

* code refactoring, dropped goccy go json
2023-10-22 15:54:08 +02:00
66 changed files with 6202 additions and 689 deletions

View File

@@ -15,3 +15,4 @@ yt-dlp-webui
session.dat session.dat
config.yml config.yml
cookies.txt cookies.txt
examples/

View File

@@ -68,7 +68,7 @@ jobs:
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels}} labels: ${{ steps.meta.outputs.labels}}

3
.gitignore vendored
View File

@@ -1,6 +1,4 @@
dist dist
package-lock.json
pnpm-lock.yaml
.pnpm-debug.log .pnpm-debug.log
node_modules node_modules
.env .env
@@ -15,3 +13,4 @@ yt-dlp-webui
session.dat session.dat
config.yml config.yml
cookies.txt cookies.txt
__debug*

4
.vscode/launch.json vendored
View File

@@ -5,11 +5,11 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Launch file", "name": "go",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "debug", "mode": "debug",
"program": "${file}" "program": "main.go"
}, },
{ {
"type": "chrome", "type": "chrome",

View File

@@ -1,11 +1,12 @@
FROM golang:1.20-alpine AS build FROM golang:alpine AS build
RUN apk update && \ RUN apk update && \
apk add nodejs npm go apk add nodejs npm
COPY . /usr/src/yt-dlp-webui COPY . /usr/src/yt-dlp-webui
WORKDIR /usr/src/yt-dlp-webui/frontend WORKDIR /usr/src/yt-dlp-webui/frontend
RUN npm install RUN npm install
RUN npm run build RUN npm run build
@@ -26,4 +27,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret ENV JWT_SECRET=secret
EXPOSE 3033 EXPOSE 3033
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml" ] ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]

View File

@@ -15,9 +15,9 @@ The bottleneck remains yt-dlp startup time.
docker pull marcobaobao/yt-dlp-webui docker pull marcobaobao/yt-dlp-webui
``` ```
```sh ```sh
# latest stable # latest dev
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
# latest dev version # latest stable version
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
``` ```
@@ -167,6 +167,8 @@ Usage yt-dlp-webui:
yt-dlp executable path (default "yt-dlp") yt-dlp executable path (default "yt-dlp")
-out string -out string
Where files will be saved (default ".") Where files will be saved (default ".")
-host string
Host where server will listen at (default "0.0.0.0")
-port int -port int
Port where server will listen at (default 3033) Port where server will listen at (default 3033)
-qs int -qs int

5
examples/Caddyfile Normal file
View File

@@ -0,0 +1,5 @@
resource.yourdomain.tld {
handle_path /yt-dlp-webui/* {
reverse_proxy 127.0.0.1:3033
}
}

37
examples/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
server {
listen 80;
server_name localhost;
location ~/yt-dlp/(.*)$ {
proxy_pass http://127.0.0.1:3033/$1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 20000m;
proxy_connect_timeout 3000;
proxy_send_timeout 3000;
proxy_read_timeout 3000;
send_timeout 3000;
}
}
}

3249
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
}, },
"author": "marcopeocchi", "author": "marcopeocchi",
"license": "MPL-2.0", "license": "MPL-2.0",
"private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
@@ -17,7 +18,6 @@
"fp-ts": "^2.16.1", "fp-ts": "^2.16.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-router-dom": "^6.17.0", "react-router-dom": "^6.17.0",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@@ -30,7 +30,7 @@
"@types/react-helmet": "^6.1.8", "@types/react-helmet": "^6.1.8",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.4.0", "@vitejs/plugin-react-swc": "^3.4.0",
"typescript": "^5.2.2", "typescript": "^5.3.2",
"vite": "^4.5.0" "vite": "^4.5.0"
} }
} }

1334
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,7 @@ import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors' import { grey } from '@mui/material/colors'
import { useMemo, useState } from 'react' import { Suspense, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet'
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet } from 'react-router-dom'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { settingsState } from './atoms/settings' import { settingsState } from './atoms/settings'
@@ -28,6 +27,7 @@ import FreeSpaceIndicator from './components/FreeSpaceIndicator'
import Logout from './components/Logout' import Logout from './components/Logout'
import SocketSubscriber from './components/SocketSubscriber' import SocketSubscriber from './components/SocketSubscriber'
import ThemeToggler from './components/ThemeToggler' import ThemeToggler from './components/ThemeToggler'
import { useI18n } from './hooks/useI18n'
import Toaster from './providers/ToasterProvider' import Toaster from './providers/ToasterProvider'
export default function Layout() { export default function Layout() {
@@ -50,124 +50,124 @@ export default function Layout() {
const toggleDrawer = () => setOpen(state => !state) const toggleDrawer = () => setOpen(state => !state)
const { i18n } = useI18n()
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<SocketSubscriber> <SocketSubscriber />
<Helmet> <Box sx={{ display: 'flex' }}>
<title> <CssBaseline />
{settings.appTitle} <AppBar position="absolute" open={open}>
</title> <Toolbar sx={{ pr: '24px' }}>
</Helmet> <IconButton
<Box sx={{ display: 'flex' }}> edge="start"
<CssBaseline /> color="inherit"
<AppBar position="absolute" open={open}> aria-label="open drawer"
<Toolbar sx={{ pr: '24px' }}> onClick={toggleDrawer}
<IconButton
edge="start"
color="inherit"
aria-label="open drawer"
onClick={toggleDrawer}
sx={{
marginRight: '36px',
...(open && { display: 'none' }),
}}
>
<Menu />
</IconButton>
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
{settings.appTitle}
</Typography>
<FreeSpaceIndicator />
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<SettingsEthernet />
<span>
&nbsp;{isConnected ? settings.serverAddr : 'not connected'}
</span>
</div>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<Toolbar
sx={{ sx={{
display: 'flex', marginRight: '36px',
alignItems: 'center', ...(open && { display: 'none' }),
justifyContent: 'flex-end',
px: [1],
}} }}
> >
<IconButton onClick={toggleDrawer}> <Menu />
<ChevronLeft /> </IconButton>
</IconButton> <Typography
</Toolbar> component="h1"
<Divider /> variant="h6"
<List component="nav"> color="inherit"
<Link to={'/'} style={ noWrap
{ sx={{ flexGrow: 1 }}
textDecoration: 'none', >
color: mode === 'dark' ? '#ffffff' : '#000000DE' {settings.appTitle}
} </Typography>
}> <Suspense fallback={i18n.t('loadingLabel')}>
<ListItemButton> <FreeSpaceIndicator />
<ListItemIcon> </Suspense>
<Dashboard /> <div style={{
</ListItemIcon> display: 'flex',
<ListItemText primary="Home" /> alignItems: 'center',
</ListItemButton> flexWrap: 'wrap',
</Link> marginLeft: '4px',
<Link to={'/archive'} style={ gap: 3,
{ }}>
textDecoration: 'none', <SettingsEthernet />
color: mode === 'dark' ? '#ffffff' : '#000000DE' <span>
} {isConnected ? settings.serverAddr : i18n.t('notConnectedText')}
}> </span>
<ListItemButton> </div>
<ListItemIcon> </Toolbar>
<DownloadIcon /> </AppBar>
</ListItemIcon> <Drawer variant="permanent" open={open}>
<ListItemText primary="Archive" /> <Toolbar
</ListItemButton>
</Link>
<Link to={'/settings'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</Link>
<ThemeToggler />
<Logout />
</List>
</Drawer>
<Box
component="main"
sx={{ sx={{
flexGrow: 1, display: 'flex',
height: '100vh', alignItems: 'center',
overflow: 'auto', justifyContent: 'flex-end',
px: [1],
}} }}
> >
<Toolbar /> <IconButton onClick={toggleDrawer}>
<Outlet /> <ChevronLeft />
</Box> </IconButton>
</Toolbar>
<Divider />
<List component="nav">
<Link to={'/'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary={i18n.t('homeButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/archive'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<DownloadIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/settings'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('settingsButtonLabel')} />
</ListItemButton>
</Link>
<ThemeToggler />
<Logout />
</List>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
height: '100vh',
overflow: 'auto',
}}
>
<Toolbar />
<Outlet />
</Box> </Box>
<Toaster /> </Box>
</SocketSubscriber> <Toaster />
</ThemeProvider> </ThemeProvider>
) )
} }

View File

@@ -1,7 +1,7 @@
--- ---
languages: languages:
english: english:
urlInput: YouTube or other supported service video URL urlInput: Video URL
statusTitle: Status statusTitle: Status
statusReady: Ready statusReady: Ready
selectFormatButton: Select format selectFormatButton: Select format
@@ -35,7 +35,18 @@ languages:
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
restartAppMessage: Needs a page reload to take effect restartAppMessage: Needs a page reload to take effect
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
french: french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut statusTitle: Statut
@@ -71,9 +82,22 @@ languages:
playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée) playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée)
restartAppMessage: Nécessite un rechargement de la page pour prendre effet restartAppMessage: Nécessite un rechargement de la page pour prendre effet
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
notConnectedText: not connected
settingsLabel: Settings
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: Nom de l'application appTitle: Nom de l'application
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
italian: italian:
urlInput: URL di YouTube o di qualsiasi altro servizio supportato urlInput: URL Video
statusTitle: Stato statusTitle: Stato
startButton: Inizia startButton: Inizia
statusReady: Pronto statusReady: Pronto
@@ -106,7 +130,18 @@ languages:
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro) playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: Nuovo download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: Titolo applicazione appTitle: Titolo applicazione
savedTemplates: Template salvati
templatesEditor: Editor template
templatesEditorNameLabel: Nome template
templatesEditorContentLabel: Contentunto template
chinese: chinese:
urlInput: YouTube 或其他受支持服务的视频网址 urlInput: YouTube 或其他受支持服务的视频网址
statusTitle: 状态 statusTitle: 状态
@@ -142,7 +177,18 @@ languages:
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待) playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
restartAppMessage: 需要刷新页面才能生效 restartAppMessage: 需要刷新页面才能生效
servedFromReverseProxyCheckbox: 处于反向代理的子目录后 servedFromReverseProxyCheckbox: 处于反向代理的子目录后
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App 标题 appTitle: App 标题
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -176,7 +222,18 @@ languages:
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
russian: russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус statusTitle: Статус
@@ -210,7 +267,18 @@ languages:
clipboardAction: URL скопирован в буфер обмена clipboardAction: URL скопирован в буфер обмена
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -244,7 +312,18 @@ languages:
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -279,7 +358,18 @@ languages:
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
catalan: catalan:
urlInput: URL de YouTube o d'un altre servei compatible urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat statusTitle: Estat
@@ -313,7 +403,18 @@ languages:
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
ukrainian: ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус statusTitle: Статус
@@ -347,7 +448,18 @@ languages:
clipboardAction: URL скопійовано в буфер обміну clipboardAction: URL скопійовано в буфер обміну
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
polish: polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status statusTitle: Status
@@ -381,4 +493,15 @@ languages:
clipboardAction: Adres URL zostanie skopiowany do schowka clipboardAction: Adres URL zostanie skopiowany do schowka
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content

View File

@@ -1,9 +1,51 @@
import { atom } from 'recoil' import { atom, selector } from 'recoil'
import { CustomTemplate } from '../types'
import { ffetch } from '../lib/httpClient'
import { serverURL } from './settings'
import { pipe } from 'fp-ts/lib/function'
import { getOrElse } from 'fp-ts/lib/Either'
export const downloadTemplateState = atom({ export const cookiesTemplateState = atom({
key: 'downloadTemplateState', key: 'cookiesTemplateState',
default: localStorage.getItem('lastDownloadTemplate') ?? '', default: localStorage.getItem('cookiesTemplate') ?? '',
effects: [ effects: [
({ onSet }) => onSet(e => localStorage.setItem('lastDownloadTemplate', e)) ({ onSet }) => onSet(e => localStorage.setItem('cookiesTemplate', e))
] ]
}) })
export const customArgsState = atom({
key: 'customArgsState',
default: localStorage.getItem('customArgs') ?? '',
effects: [
({ onSet }) => onSet(e => localStorage.setItem('customArgs', e))
]
})
export const filenameTemplateState = atom({
key: 'filenameTemplateState',
default: localStorage.getItem('lastFilenameTemplate') ?? '',
effects: [
({ onSet }) => onSet(e => localStorage.setItem('lastFilenameTemplate', e))
]
})
export const downloadTemplateState = selector({
key: 'downloadTemplateState',
get: ({ get }) =>
`${get(customArgsState)} ${get(cookiesTemplateState)}`
.replace(/ +/g, ' ')
.trim()
})
export const savedTemplatesState = selector<CustomTemplate[]>({
key: 'savedTemplatesState',
get: async ({ get }) => {
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
const either = await task()
return pipe(
either,
getOrElse(() => new Array<CustomTemplate>())
)
}
})

View File

@@ -1,7 +1,22 @@
import { atom } from 'recoil' import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/lib/function'
import { atom, selector } from 'recoil'
import { RPCResult } from '../types' import { RPCResult } from '../types'
export const activeDownloadsState = atom<RPCResult[] | undefined>({ export const downloadsState = atom<O.Option<RPCResult[]>>({
key: 'activeDownloadsState', key: 'downloadsState',
default: undefined default: O.none
})
export const loadingDownloadsState = selector<boolean>({
key: 'loadingDownloadsState',
get: ({ get }) => O.isNone(get(downloadsState))
})
export const activeDownloadsState = selector<RPCResult[]>({
key: 'activeDownloadsState',
get: ({ get }) => pipe(
get(downloadsState),
O.getOrElse(() => new Array<RPCResult>())
)
}) })

View File

@@ -5,8 +5,10 @@ import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings'
export const rpcClientState = selector({ export const rpcClientState = selector({
key: 'rpcClientState', key: 'rpcClientState',
get: ({ get }) => get: ({ get }) =>
new RPCClient(get(rpcHTTPEndpoint), get(rpcWebSocketEndpoint)), new RPCClient(
set: ({ get }) => get(rpcHTTPEndpoint),
new RPCClient(get(rpcHTTPEndpoint), get(rpcWebSocketEndpoint)), get(rpcWebSocketEndpoint),
localStorage.getItem('token') ?? ''
),
dangerouslyAllowMutability: true, dangerouslyAllowMutability: true,
}) })

View File

@@ -73,7 +73,7 @@ export const serverPortState = atom<number>({
export const latestCliArgumentsState = atom<string>({ export const latestCliArgumentsState = atom<string>({
key: 'latestCliArgumentsState', key: 'latestCliArgumentsState',
default: localStorage.getItem('cli-args') || '', default: localStorage.getItem('cli-args') || '--no-mtime',
effects: [ effects: [
({ onSet }) => ({ onSet }) =>
onSet(a => localStorage.setItem('cli-args', a.toString())) onSet(a => localStorage.setItem('cli-args', a.toString()))
@@ -127,7 +127,7 @@ export const listViewState = atom({
export const servedFromReverseProxyState = atom({ export const servedFromReverseProxyState = atom({
key: 'servedFromReverseProxyState', key: 'servedFromReverseProxyState',
default: localStorage.getItem('reverseProxy') === "true", default: localStorage.getItem('reverseProxy') === "true" || window.location.port == "",
effects: [ effects: [
({ onSet }) => ({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxy', a.toString())) onSet(a => localStorage.setItem('reverseProxy', a.toString()))
@@ -153,7 +153,7 @@ export const serverAddressAndPortState = selector({
export const serverURL = selector({ export const serverURL = selector({
key: 'serverURL', key: 'serverURL',
get: ({ get }) => get: ({ get }) =>
`${window.location.protocol}//${get(serverAddressState)}:${get(serverPortState)}` `${window.location.protocol}//${get(serverAddressAndPortState)}`
}) })
export const rpcWebSocketEndpoint = selector({ export const rpcWebSocketEndpoint = selector({

View File

@@ -23,18 +23,20 @@ export const isDownloadingState = atom({
default: false default: false
}) })
// export const freeSpaceBytesState = selector({ export const freeSpaceBytesState = selector({
// key: 'freeSpaceBytesState', key: 'freeSpaceBytesState',
// get: async ({ get }) => { get: async ({ get }) => {
// const res = await get(rpcClientState).freeSpace() const res = await get(rpcClientState).freeSpace()
// return res.result .catch(() => ({ result: 0 }))
// } return res.result
// }) }
})
export const availableDownloadPathsState = selector({ export const availableDownloadPathsState = selector({
key: 'availableDownloadPathsState', key: 'availableDownloadPathsState',
get: async ({ get }) => { get: async ({ get }) => {
const res = await get(rpcClientState).directoryTree() const res = await get(rpcClientState).directoryTree()
.catch(() => ({ result: [] }))
return res.result return res.result
} }
}) })

View File

@@ -1,7 +1,7 @@
import { AlertColor } from '@mui/material' import { AlertColor } from '@mui/material'
import { atom } from 'recoil' import { atom } from 'recoil'
type Toast = { export type Toast = {
open: boolean, open: boolean,
message: string message: string
autoClose: boolean autoClose: boolean

View File

@@ -6,7 +6,7 @@ import { pipe } from 'fp-ts/lib/function'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs' import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { downloadTemplateState } from '../atoms/downloadTemplate' import { cookiesTemplateState } from '../atoms/downloadTemplate'
import { cookiesState, serverURL } from '../atoms/settings' import { cookiesState, serverURL } from '../atoms/settings'
import { useSubscription } from '../hooks/observable' import { useSubscription } from '../hooks/observable'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
@@ -18,49 +18,49 @@ const validateCookie = (cookie: string) => pipe(
cookie => cookie.replaceAll('\t', ' '), cookie => cookie.replaceAll('\t', ' '),
cookie => cookie.split(' '), cookie => cookie.split(' '),
E.of, E.of,
E.chain( E.flatMap(
E.fromPredicate( E.fromPredicate(
f => f.length === 7, f => f.length === 7,
() => `missing parts` () => `missing parts`
) )
), ),
E.chain( E.flatMap(
E.fromPredicate( E.fromPredicate(
f => f[0].length > 0, f => f[0].length > 0,
() => 'missing domain' () => 'missing domain'
) )
), ),
E.chain( E.flatMap(
E.fromPredicate( E.fromPredicate(
f => f[1] === 'TRUE' || f[1] === 'FALSE', f => f[1] === 'TRUE' || f[1] === 'FALSE',
() => `invalid include subdomains` () => `invalid include subdomains`
) )
), ),
E.chain( E.flatMap(
E.fromPredicate( E.fromPredicate(
f => f[2].length > 0, f => f[2].length > 0,
() => 'invalid path' () => 'invalid path'
) )
), ),
E.chain( E.flatMap(
E.fromPredicate( E.fromPredicate(
f => f[3] === 'TRUE' || f[3] === 'FALSE', f => f[3] === 'TRUE' || f[3] === 'FALSE',
() => 'invalid secure flag' () => 'invalid secure flag'
) )
), ),
E.chain( E.flatMap(
E.fromPredicate( E.fromPredicate(
f => isFinite(Number(f[4])), f => isFinite(Number(f[4])),
() => 'invalid expiration' () => 'invalid expiration'
) )
), ),
E.chain( E.flatMap(
E.fromPredicate( E.fromPredicate(
f => f[5].length > 0, f => f[5].length > 0,
() => 'invalid name' () => 'invalid name'
) )
), ),
E.chain( E.flatMap(
E.fromPredicate( E.fromPredicate(
f => f[6].length > 0, f => f[6].length > 0,
() => 'invalid value' () => 'invalid value'
@@ -70,7 +70,7 @@ const validateCookie = (cookie: string) => pipe(
const CookiesTextField: React.FC = () => { const CookiesTextField: React.FC = () => {
const serverAddr = useRecoilValue(serverURL) const serverAddr = useRecoilValue(serverURL)
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState) const [, setCookies] = useRecoilState(cookiesTemplateState)
const [savedCookies, setSavedCookies] = useRecoilState(cookiesState) const [savedCookies, setSavedCookies] = useRecoilState(cookiesState)
const { pushMessage } = useToast() const { pushMessage } = useToast()
@@ -124,22 +124,18 @@ const CookiesTextField: React.FC = () => {
validateNetscapeCookies, validateNetscapeCookies,
O.fromPredicate(f => f === true), O.fromPredicate(f => f === true),
O.match( O.match(
() => { () => setCookies(''),
if (customArgs.includes(flag)) {
setCustomArgs(a => a.replace(flag, ''))
}
},
async () => { async () => {
pipe( pipe(
await submitCookies(cookies), await submitCookies(cookies),
E.match( E.match(
(l) => pushMessage(`${l}`, 'error'), (l) => pushMessage(`${l}`, 'error'),
() => pushMessage(`Saved Netscape cookies`, 'success') () => {
pushMessage(`Saved Netscape cookies`, 'success')
setCookies(flag)
}
) )
) )
if (!customArgs.includes(flag)) {
setCustomArgs(a => `${a} ${flag}`)
}
} }
) )
) )

View File

@@ -15,6 +15,7 @@ import {
Stack, Stack,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useCallback } from 'react'
import { RPCResult } from '../types' import { RPCResult } from '../types'
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils' import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils'
@@ -34,11 +35,17 @@ const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
} }
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => { const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
const isCompleted = () => download.progress.percentage === '-1' const isCompleted = useCallback(
() => download.progress.percentage === '-1',
[download.progress.percentage]
)
const percentageToNumber = () => isCompleted() const percentageToNumber = useCallback(
? 100 () => isCompleted()
: Number(download.progress.percentage.replace('%', '')) ? 100
: Number(download.progress.percentage.replace('%', '')),
[download.progress.percentage, isCompleted]
)
return ( return (
<Card> <Card>

View File

@@ -1,7 +1,9 @@
import { FileUpload } from '@mui/icons-material' import { FileUpload } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import { import {
Autocomplete,
Backdrop, Backdrop,
Box,
Button, Button,
Checkbox, Checkbox,
Container, Container,
@@ -10,10 +12,7 @@ import {
Grid, Grid,
IconButton, IconButton,
InputAdornment, InputAdornment,
InputLabel,
MenuItem,
Paper, Paper,
Select,
TextField TextField
} from '@mui/material' } from '@mui/material'
import AppBar from '@mui/material/AppBar' import AppBar from '@mui/material/AppBar'
@@ -23,14 +22,17 @@ import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { TransitionProps } from '@mui/material/transitions' import { TransitionProps } from '@mui/material/transitions'
import { import {
FC,
Suspense,
forwardRef, forwardRef,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useTransition useTransition
} from 'react' } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { downloadTemplateState } from '../atoms/downloadTemplate' import { customArgsState, downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate'
import { settingsState } from '../atoms/settings' import { settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status' import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
@@ -39,6 +41,7 @@ import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser' import { CliArguments } from '../lib/argsParser'
import type { DLMetadata } from '../types' import type { DLMetadata } from '../types'
import { isValidURL, toFormatArgs } from '../utils' import { isValidURL, toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions'
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
@@ -55,48 +58,45 @@ type Props = {
onDownloadStart: (url: string) => void onDownloadStart: (url: string) => void
} }
export default function DownloadDialog({ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
open,
onClose,
onDownloadStart
}: Props) {
// recoil state
const settings = useRecoilValue(settingsState) const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState) const isConnected = useRecoilValue(connectedState)
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState) const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
const downloadTemplate = useRecoilValue(downloadTemplateState)
// ephemeral state
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>() 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] = useRecoilState(downloadTemplateState) const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [downloadPath, setDownloadPath] = useState(0) const [downloadPath, setDownloadPath] = useState('')
const [fileNameOverride, setFilenameOverride] = useState('') const [filenameTemplate, setFilenameTemplate] = useRecoilState(
filenameTemplateState
)
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [workingUrl, setWorkingUrl] = useState('') const [workingUrl, setWorkingUrl] = useState('')
const [isPlaylist, setIsPlaylist] = useState(false) const [isPlaylist, setIsPlaylist] = useState(false)
// memos
const cliArgs = useMemo(() => const cliArgs = useMemo(() =>
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs] new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
) )
// context
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
// refs
const urlInputRef = useRef<HTMLInputElement>(null) const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null) const customFilenameInputRef = useRef<HTMLInputElement>(null)
// transitions
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
useEffect(() => {
setCustomArgs('')
}, [open])
/** /**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket * Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
*/ */
@@ -106,13 +106,13 @@ export default function DownloadDialog({
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat) if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat) if (pickedBestFormat !== '') codes.push(pickedBestFormat)
client.download( client.download({
immediate || url || workingUrl, url: immediate || url || workingUrl,
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`, args: `${cliArgs.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
availableDownloadPaths[downloadPath] ?? '', pathOverride: downloadPath ?? '',
fileNameOverride, renameTo: settings.fileRenaming ? filenameTemplate : '',
isPlaylist, playlist: isPlaylist,
) })
setUrl('') setUrl('')
setWorkingUrl('') setWorkingUrl('')
@@ -120,7 +120,7 @@ export default function DownloadDialog({
setTimeout(() => { setTimeout(() => {
resetInput() resetInput()
setDownloadFormats(undefined) setDownloadFormats(undefined)
onDownloadStart(url) onDownloadStart(immediate || url || workingUrl)
}, 250) }, 250)
} }
@@ -141,30 +141,16 @@ export default function DownloadDialog({
}) })
} }
/**
* Update the url state whenever the input value changes
* @param e Input change event
*/
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value) setUrl(e.target.value)
} }
/** const handleFilenameTemplateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
* Update the filename override state whenever the input value changes setFilenameTemplate(e.target.value)
* @param e Input change event
*/
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilenameOverride(e.target.value)
localStorage.setItem('last-filename-override', e.target.value)
} }
/**
* Update the custom args state whenever the input value changes
* @param e Input change event
*/
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomArgs(e.target.value) setCustomArgs(e.target.value)
localStorage.setItem("last-input-args", e.target.value)
} }
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => { const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -189,36 +175,40 @@ export default function DownloadDialog({
} }
return ( return (
<div> <Dialog
<Dialog fullScreen
fullScreen open={open}
open={open} onClose={onClose}
onClose={onClose} TransitionComponent={Transition}
TransitionComponent={Transition} >
> <Backdrop
<Backdrop sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }} open={isPending}
open={isPending} />
/> <AppBar sx={{ position: 'relative' }}>
<AppBar sx={{ position: 'relative' }}> <Toolbar>
<Toolbar> <IconButton
<IconButton edge="start"
edge="start" color="inherit"
color="inherit" onClick={onClose}
onClick={onClose} aria-label="close"
aria-label="close" >
> <CloseIcon />
<CloseIcon /> </IconButton>
</IconButton> <Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div"> Download
Download </Typography>
</Typography> </Toolbar>
</Toolbar> </AppBar>
</AppBar> <Box sx={{
<Container sx={{ my: 4 }}> backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Container sx={{ my: 4 }} >
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12}> <Grid item xs={12}>
<Paper <Paper
elevation={4}
sx={{ sx={{
p: 2, p: 2,
display: 'flex', display: 'flex',
@@ -279,14 +269,15 @@ export default function DownloadDialog({
} }
{ {
settings.fileRenaming && settings.fileRenaming &&
<Grid item xs={8}> <Grid item xs={settings.pathOverriding ? 8 : 12}>
<TextField <TextField
sx={{ mt: 1 }}
ref={customFilenameInputRef} ref={customFilenameInputRef}
fullWidth fullWidth
label={i18n.t('customFilename')} label={i18n.t('customFilename')}
variant="outlined" variant="outlined"
value={fileNameOverride} value={filenameTemplate}
onChange={handleFilenameOverrideChange} onChange={handleFilenameTemplateChange}
disabled={ disabled={
!isConnected || !isConnected ||
(settings.formatSelection && downloadFormats != null) (settings.formatSelection && downloadFormats != null)
@@ -298,22 +289,32 @@ export default function DownloadDialog({
settings.pathOverriding && settings.pathOverriding &&
<Grid item xs={4}> <Grid item xs={4}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('customPath')}</InputLabel> <Autocomplete
<Select disablePortal
label={i18n.t('customPath')} options={availableDownloadPaths.map((dir) => ({ label: dir, dir }))}
defaultValue={0} autoHighlight
variant={'outlined'} getOptionLabel={(option) => option.label}
value={downloadPath} onChange={(_, value) => {
onChange={(e) => setDownloadPath(Number(e.target.value))} setDownloadPath(value?.dir!)
> }}
{availableDownloadPaths.map((val: string, idx: number) => ( renderOption={(props, option) => (
<MenuItem key={idx} value={idx}>{val}</MenuItem> <Box
))} component="li"
</Select> sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...props}>
{option.label}
</Box>
)}
sx={{ width: '100%', mt: 1 }}
renderInput={(params) => <TextField {...params} label={i18n.t('customPath')} />}
/>
</FormControl> </FormControl>
</Grid> </Grid>
} }
</Grid> </Grid>
<Suspense>
<ExtraDownloadOptions />
</Suspense>
<Grid container spacing={1} pt={2} justifyContent="space-between"> <Grid container spacing={1} pt={2} justifyContent="space-between">
<Grid item> <Grid item>
<Button <Button
@@ -369,7 +370,9 @@ export default function DownloadDialog({
pickedAudioFormat={pickedAudioFormat} pickedAudioFormat={pickedAudioFormat}
/>} />}
</Container> </Container>
</Dialog> </Box>
</div> </Dialog>
) )
} }
export default DownloadDialog

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads' import { loadingDownloadsState } from '../atoms/downloads'
import { listViewState } from '../atoms/settings' import { listViewState } from '../atoms/settings'
import { loadingAtom } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
import DownloadsCardView from './DownloadsCardView' import DownloadsCardView from './DownloadsCardView'
@@ -8,15 +8,17 @@ import DownloadsListView from './DownloadsListView'
const Downloads: React.FC = () => { const Downloads: React.FC = () => {
const listView = useRecoilValue(listViewState) const listView = useRecoilValue(listViewState)
const active = useRecoilValue(activeDownloadsState) const loadingDownloads = useRecoilValue(loadingDownloadsState)
const [isLoading, setIsLoading] = useRecoilState(loadingAtom) const [isLoading, setIsLoading] = useRecoilState(loadingAtom)
useEffect(() => { useEffect(() => {
if (active) { if (loadingDownloads) {
setIsLoading(false) setIsLoading(true)
return
} }
}, [active?.length, isLoading]) setIsLoading(false)
}, [loadingDownloads, isLoading])
if (listView) { if (listView) {
return ( return (

View File

@@ -7,7 +7,7 @@ import { useRPC } from '../hooks/useRPC'
import DownloadCard from './DownloadCard' import DownloadCard from './DownloadCard'
const DownloadsCardView: React.FC = () => { const DownloadsCardView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? [] const downloads = useRecoilValue(activeDownloadsState)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
@@ -20,13 +20,11 @@ const DownloadsCardView: React.FC = () => {
{ {
downloads.map(download => ( downloads.map(download => (
<Grid item xs={4} sm={8} md={6} key={download.id}> <Grid item xs={4} sm={8} md={6} key={download.id}>
<> <DownloadCard
<DownloadCard download={download}
download={download} onStop={() => abort(download.id)}
onStop={() => abort(download.id)} onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')} />
/>
</>
</Grid> </Grid>
)) ))
} }

View File

@@ -18,7 +18,7 @@ import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
const DownloadsListView: React.FC = () => { const DownloadsListView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? [] const downloads = useRecoilValue(activeDownloadsState)
const { client } = useRPC() const { client } = useRPC()

View File

@@ -0,0 +1,37 @@
import { Autocomplete, Box, TextField } from '@mui/material'
import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
import { useI18n } from '../hooks/useI18n'
const ExtraDownloadOptions: React.FC = () => {
const { i18n } = useI18n()
const customTemplates = useRecoilValue(savedTemplatesState)
const [, setCustomArgs] = useRecoilState(customArgsState)
return (
<>
<Autocomplete
disablePortal
options={customTemplates.map(({ name, content }) => ({ label: name, content }))}
autoHighlight
getOptionLabel={(option) => option.label}
onChange={(_, value) => {
setCustomArgs(value?.content!)
}}
renderOption={(props, option) => (
<Box
component="li"
sx={{ mr: 2, flexShrink: 0 }}
{...props}>
{option.label}
</Box>
)}
sx={{ width: '100%', mt: 2 }}
renderInput={(params) => <TextField {...params} label={i18n.t('savedTemplates')} />}
/>
</>
)
}
export default ExtraDownloadOptions

View File

@@ -1,26 +1,21 @@
import StorageIcon from '@mui/icons-material/Storage' import StorageIcon from '@mui/icons-material/Storage'
import { useEffect, useState } from 'react' import { useRecoilValue } from 'recoil'
import { freeSpaceBytesState } from '../atoms/status'
import { formatGiB } from '../utils' import { formatGiB } from '../utils'
import { useRPC } from '../hooks/useRPC'
const FreeSpaceIndicator = () => { const FreeSpaceIndicator = () => {
const [freeSpace, setFreeSpace] = useState(0) const freeSpace = useRecoilValue(freeSpaceBytesState)
const { client } = useRPC()
useEffect(() => {
client.freeSpace().then(r => setFreeSpace(r.result))
}, [client])
return ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 3
}}> }}>
<StorageIcon /> <StorageIcon />
<span> <span>
&nbsp;{formatGiB(freeSpace)}&nbsp; {formatGiB(freeSpace)}
</span> </span>
</div> </div>
) )

View File

@@ -1,32 +1,42 @@
import { useState } from 'react' import { Suspense, useState } from 'react'
import { useRecoilState } from 'recoil' import { useRecoilState } from 'recoil'
import { loadingAtom } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
import { useToast } from '../hooks/toast'
import DownloadDialog from './DownloadDialog' import DownloadDialog from './DownloadDialog'
import HomeSpeedDial from './HomeSpeedDial' import HomeSpeedDial from './HomeSpeedDial'
import { useToast } from '../hooks/toast' import TemplatesEditor from './TemplatesEditor'
const HomeActions: React.FC = () => { const HomeActions: React.FC = () => {
const [, setIsLoading] = useRecoilState(loadingAtom) const [, setIsLoading] = useRecoilState(loadingAtom)
const [openDialog, setOpenDialog] = useState(false)
const [openDownload, setOpenDownload] = useState(false)
const [openEditor, setOpenEditor] = useState(false)
const { pushMessage } = useToast() const { pushMessage } = useToast()
return ( return (
<> <>
<HomeSpeedDial <HomeSpeedDial
onOpen={() => setOpenDialog(true)} onDownloadOpen={() => setOpenDownload(true)}
onEditorOpen={() => setOpenEditor(true)}
/> />
<DownloadDialog <Suspense>
open={openDialog} <DownloadDialog
onClose={() => { open={openDownload}
setOpenDialog(false) onClose={() => {
setIsLoading(true) setOpenDownload(false)
}} setIsLoading(true)
onDownloadStart={(url) => { }}
pushMessage(`Requested ${url}`, 'info') onDownloadStart={(url) => {
setOpenDialog(false) pushMessage(`Requested ${url}`, 'info')
setIsLoading(true) setOpenDownload(false)
}} setIsLoading(true)
}}
/>
</Suspense>
<TemplatesEditor
open={openEditor}
onClose={() => setOpenEditor(false)}
/> />
</> </>
) )

View File

@@ -1,4 +1,5 @@
import AddCircleIcon from '@mui/icons-material/AddCircle' import AddCircleIcon from '@mui/icons-material/AddCircle'
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever' import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import { import {
@@ -12,10 +13,11 @@ import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
type Props = { type Props = {
onOpen: () => void onDownloadOpen: () => void
onEditorOpen: () => void
} }
const HomeSpeedDial: React.FC<Props> = ({ onOpen }) => { const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const [, setListView] = useRecoilState(listViewState) const [, setListView] = useRecoilState(listViewState)
const { i18n } = useI18n() const { i18n } = useI18n()
@@ -39,10 +41,15 @@ const HomeSpeedDial: React.FC<Props> = ({ onOpen }) => {
tooltipTitle={i18n.t('abortAllButton')} tooltipTitle={i18n.t('abortAllButton')}
onClick={abort} onClick={abort}
/> />
<SpeedDialAction
icon={<BuildCircleIcon />}
tooltipTitle={i18n.t('templatesEditor')}
onClick={onEditorOpen}
/>
<SpeedDialAction <SpeedDialAction
icon={<AddCircleIcon />} icon={<AddCircleIcon />}
tooltipTitle={`New download`} tooltipTitle={i18n.t('newDownloadButton')}
onClick={onOpen} onClick={onDownloadOpen}
/> />
</SpeedDial> </SpeedDial>
) )

View File

@@ -1,26 +1,27 @@
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import LogoutIcon from '@mui/icons-material/Logout' import LogoutIcon from '@mui/icons-material/Logout'
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
export default function Logout() { export default function Logout() {
const navigate = useNavigate() const navigate = useNavigate()
const url = useRecoilValue(serverURL) const url = useRecoilValue(serverURL)
const logout = async () => { const logout = async () => {
const res = await fetch(`${url}/auth/logout`) localStorage.removeItem('token')
if (res.ok) { navigate('/login')
navigate('/login')
}
} }
const { i18n } = useI18n()
return ( return (
<ListItemButton onClick={logout}> <ListItemButton onClick={logout}>
<ListItemIcon> <ListItemIcon>
<LogoutIcon /> <LogoutIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="RPC authentication" /> <ListItemText primary={i18n.t('rpcAuthenticationLabel')} />
</ListItemButton> </ListItemButton>
) )
} }

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react' import * as O from 'fp-ts/Option'
import { useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { interval, share, take } from 'rxjs' import { take, timer } from 'rxjs'
import { activeDownloadsState } from '../atoms/downloads' import { downloadsState } from '../atoms/downloads'
import { serverAddressAndPortState } from '../atoms/settings' import { serverAddressAndPortState } from '../atoms/settings'
import { connectedState } from '../atoms/status' import { connectedState } from '../atoms/status'
import { useSubscription } from '../hooks/observable' import { useSubscription } from '../hooks/observable'
@@ -12,9 +14,9 @@ import { datetimeCompareFunc, isRPCResponse } from '../utils'
interface Props extends React.HTMLAttributes<HTMLBaseElement> { } interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
const SocketSubscriber: React.FC<Props> = ({ children }) => { const SocketSubscriber: React.FC<Props> = () => {
const [, setIsConnected] = useRecoilState(connectedState) const [connected, setIsConnected] = useRecoilState(connectedState)
const [, setActive] = useRecoilState(activeDownloadsState) const [, setDownloads] = useRecoilState(downloadsState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState) const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
@@ -22,45 +24,59 @@ const SocketSubscriber: React.FC<Props> = ({ children }) => {
const { client } = useRPC() const { client } = useRPC()
const { pushMessage } = useToast() const { pushMessage } = useToast()
const sharedSocket$ = useMemo(() => client.socket$.pipe(share()), []) const navigate = useNavigate()
const socketOnce$ = useMemo(() => sharedSocket$.pipe(take(1)), [])
useSubscription(socketOnce$, () => { const socketOnce$ = useMemo(() => client.socket$.pipe(take(1)), [])
setIsConnected(true)
pushMessage(
`${i18n.t('toastConnected')} (${serverAddressAndPort})`,
"success"
)
})
useSubscription(sharedSocket$, useEffect(() => {
(event) => { if (!connected) {
socketOnce$.subscribe(() => {
setIsConnected(true)
pushMessage(
`${i18n.t('toastConnected')} (${serverAddressAndPort})`,
"success"
)
})
}
}, [connected])
useSubscription(
client.socket$,
event => {
if (!isRPCResponse(event)) { return } if (!isRPCResponse(event)) { return }
if (!Array.isArray(event.result)) { return } if (!Array.isArray(event.result)) { return }
setActive( if (event.result) {
(event.result || []) return setDownloads(
.filter(f => !!f.info.url) O.of(event.result
.sort((a, b) => datetimeCompareFunc( .filter(f => !!f.info.url).sort((a, b) => datetimeCompareFunc(
b.info.created_at, b.info.created_at,
a.info.created_at, a.info.created_at,
)) ))
) )
)
}
setDownloads(O.none)
}, },
(err) => { err => {
console.error(err) console.error(err)
pushMessage( pushMessage(
`${i18n.t('rpcConnErr')} (${serverAddressAndPort})`, `${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
"error" "error"
) ),
navigate(`/error`)
} }
) )
useSubscription(interval(1000), () => client.running()) useEffect(() => {
if (connected) {
const sub = timer(0, 1000).subscribe(() => client.running())
return ( return () => sub.unsubscribe()
<>{children}</> }
) }, [connected, client])
return null
} }
export default SocketSubscriber export default SocketSubscriber

View File

@@ -25,7 +25,7 @@ export default function Splash() {
const { i18n } = useI18n() const { i18n } = useI18n()
const activeDownloads = useRecoilValue(activeDownloadsState) const activeDownloads = useRecoilValue(activeDownloadsState)
if (!activeDownloads || activeDownloads.length !== 0) { if (activeDownloads.length !== 0) {
return null return null
} }

View File

@@ -0,0 +1,226 @@
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
import DeleteIcon from '@mui/icons-material/Delete'
import {
AppBar,
Backdrop,
Box,
Button,
Dialog,
Grid,
IconButton,
Paper,
Slide,
TextField,
Toolbar,
Typography
} from '@mui/material'
import { TransitionProps } from '@mui/material/transitions'
import { matchW } from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/function'
import { forwardRef, useEffect, useState, useTransition } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient'
import { CustomTemplate } from '../types'
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />
})
interface Props extends React.HTMLAttributes<HTMLBaseElement> {
open: boolean
onClose: () => void
}
const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
const [templateName, setTemplateName] = useState('')
const [templateContent, setTemplateContent] = useState('')
const serverAddr = useRecoilValue(serverURL)
const [isPending, startTransition] = useTransition()
const [templates, setTemplates] = useState<CustomTemplate[]>([])
const { i18n } = useI18n()
const { pushMessage } = useToast()
useEffect(() => {
if (open) {
getTemplates()
}
}, [open])
const getTemplates = async () => {
const task = ffetch<CustomTemplate[]>(`${serverAddr}/api/v1/template/all`)
const either = await task()
pipe(
either,
matchW(
(l) => pushMessage(l),
(r) => setTemplates(r)
)
)
}
const addTemplate = async () => {
const task = ffetch<unknown>(`${serverAddr}/api/v1/template`, {
method: 'POST',
body: JSON.stringify({
name: templateName,
content: templateContent,
})
})
const either = await task()
pipe(
either,
matchW(
(l) => pushMessage(l, 'warning'),
() => {
pushMessage('Added template')
getTemplates()
setTemplateName('')
setTemplateContent('')
}
)
)
}
const deleteTemplate = async (id: string) => {
const task = ffetch<unknown>(`${serverAddr}/api/v1/template/${id}`, {
method: 'DELETE',
})
const either = await task()
pipe(
either,
matchW(
(l) => pushMessage(l, 'warning'),
() => {
pushMessage('Deleted template')
getTemplates()
}
)
)
}
return (
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isPending}
/>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={onClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
{i18n.t('templatesEditor')}
</Typography>
</Toolbar>
</AppBar>
<Box sx={{
backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Grid container spacing={2} sx={{ p: 4 }}>
<Grid item xs={12}>
<Paper
elevation={4}
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container spacing={2} justifyContent="center" alignItems="center">
<Grid item xs={3}>
<TextField
fullWidth
label={i18n.t('templatesEditorNameLabel')}
onChange={e => setTemplateName(e.currentTarget.value)}
value={templateName}
/>
</Grid>
<Grid item xs={9}>
<TextField
fullWidth
label={i18n.t('templatesEditorContentLabel')}
onChange={e => setTemplateContent(e.currentTarget.value)}
value={templateContent}
InputProps={{
endAdornment: <Button
variant='contained'
onClick={() => startTransition(() => { addTemplate() })}
>
<AddIcon />
</Button>
}}
/>
</Grid>
</Grid>
{templates.map(template => (
<Grid
container
spacing={2}
justifyContent="center"
alignItems="center"
key={template.id}
sx={{ mt: 1 }}
>
<Grid item xs={3}>
<TextField
fullWidth
label={i18n.t('templatesEditorNameLabel')}
value={template.name}
/>
</Grid>
<Grid item xs={9}>
<TextField
fullWidth
label={i18n.t('templatesEditorContentLabel')}
value={template.content}
InputProps={{
endAdornment: <Button
variant='contained'
onClick={() => {
startTransition(() => { deleteTemplate(template.id) })
}}>
<DeleteIcon />
</Button>
}}
/>
</Grid>
</Grid>
))}
</Paper>
</Grid>
</Grid>
</Box>
</Dialog >
)
}
export default TemplatesEditor

View File

@@ -4,6 +4,7 @@ import BrightnessAuto from '@mui/icons-material/BrightnessAuto'
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useRecoilState } from 'recoil' import { useRecoilState } from 'recoil'
import { Theme, themeState } from '../atoms/settings' import { Theme, themeState } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
const ThemeToggler: React.FC = () => { const ThemeToggler: React.FC = () => {
const [theme, setTheme] = useRecoilState(themeState) const [theme, setTheme] = useRecoilState(themeState)
@@ -17,6 +18,8 @@ const ThemeToggler: React.FC = () => {
const themes: Theme[] = ['system', 'light', 'dark'] const themes: Theme[] = ['system', 'light', 'dark']
const currentTheme = themes.indexOf(theme) const currentTheme = themes.indexOf(theme)
const { i18n } = useI18n()
return ( return (
<ListItemButton onClick={() => { <ListItemButton onClick={() => {
setTheme(themes[(currentTheme + 1) % themes.length]) setTheme(themes[(currentTheme + 1) % themes.length])
@@ -24,7 +27,7 @@ const ThemeToggler: React.FC = () => {
<ListItemIcon> <ListItemIcon>
{actions[theme]} {actions[theme]}
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Toggle theme" /> <ListItemText primary={i18n.t('themeTogglerLabel')} />
</ListItemButton> </ListItemButton>
) )
} }

View File

@@ -1,12 +1,10 @@
export class CliArguments { export class CliArguments {
private _extractAudio: boolean private _extractAudio: boolean
private _noMTime: boolean private _noMTime: boolean
private _proxy: string
constructor(extractAudio = false, noMTime = true) { constructor(extractAudio = false, noMTime = true) {
this._extractAudio = extractAudio this._extractAudio = extractAudio
this._noMTime = noMTime this._noMTime = noMTime
this._proxy = ""
} }
public get extractAudio(): boolean { public get extractAudio(): boolean {
@@ -46,7 +44,14 @@ export class CliArguments {
return args.trim() return args.trim()
} }
private reset() {
this._extractAudio = false
this._noMTime = false
}
public fromString(str: string): CliArguments { public fromString(str: string): CliArguments {
this.reset()
if (str) { if (str) {
if (str.includes('-x')) { if (str.includes('-x')) {
this._extractAudio = true this._extractAudio = true

View File

@@ -1,15 +1,13 @@
import { tryCatch } from 'fp-ts/TaskEither' import { tryCatch } from 'fp-ts/TaskEither'
import { flow } from 'fp-ts/lib/function'
export const ffetch = <T>(url: string, opt?: RequestInit) => flow( export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
tryCatch( () => fetcher<T>(url, opt),
() => fetcher<T>(url, opt), (e) => `error while fetching: ${e}`
(e) => `error while fetching: ${e}`
)
) )
const fetcher = async <T>(url: string, opt?: RequestInit) => { const fetcher = async <T>(url: string, opt?: RequestInit) => {
const res = await fetch(url, opt) const jwt = localStorage.getItem('token')
if (opt && !opt.headers) { if (opt && !opt.headers) {
opt.headers = { opt.headers = {
@@ -17,6 +15,12 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
} }
} }
if (opt?.headers) {
opt.headers = { ...opt.headers, 'X-Authentication': jwt ?? '' }
}
const res = await fetch(url, opt)
if (!res.ok) { if (!res.ok) {
throw await res.text() throw await res.text()
} }

View File

@@ -4,9 +4,10 @@ import i18n from "../assets/i18n.yaml"
export default class I18nBuilder { export default class I18nBuilder {
private language: string private language: string
private textMap = i18n.languages private textMap = i18n.languages
private current: string[]
constructor(language: string) { constructor(language: string) {
this.language = language this.setLanguage(language)
} }
getLanguage(): string { getLanguage(): string {
@@ -15,13 +16,12 @@ export default class I18nBuilder {
setLanguage(language: string): void { setLanguage(language: string): void {
this.language = language this.language = language
this.current = this.textMap[this.language]
} }
t(key: string): string { t(key: string): string {
const map = this.textMap[this.language] if (this.current) {
if (map) { return this.current[key] ?? 'caption not defined'
const translation = map[key]
return translation ?? 'caption not defined'
} }
return 'caption not defined' return 'caption not defined'
} }

View File

@@ -1,21 +1,33 @@
import { Observable, share } from 'rxjs' import { Observable } from 'rxjs'
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types' import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
import { WebSocketSubject, webSocket } from 'rxjs/webSocket' import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
type DownloadRequestArgs = {
url: string,
args: string,
pathOverride?: string,
renameTo?: string,
playlist?: boolean
}
export class RPCClient { export class RPCClient {
private seq: number private seq: number
private httpEndpoint: string private httpEndpoint: string
private readonly _socket$: WebSocketSubject<any> private readonly _socket$: WebSocketSubject<any>
private readonly token?: string
constructor(httpEndpoint: string, webSocketEndpoint: string) { constructor(httpEndpoint: string, webSocketEndpoint: string, token?: string) {
this.seq = 0 this.seq = 0
this.httpEndpoint = httpEndpoint this.httpEndpoint = httpEndpoint
this._socket$ = webSocket<any>(webSocketEndpoint) this._socket$ = webSocket<any>({
url: token ? `${webSocketEndpoint}?token=${token}` : webSocketEndpoint
})
this.token = token
} }
public get socket$(): Observable<RPCResponse<RPCResult[]>> { public get socket$(): Observable<RPCResponse<RPCResult[]>> {
return this._socket$.asObservable() return this._socket$
} }
private incrementSeq() { private incrementSeq() {
@@ -29,9 +41,19 @@ export class RPCClient {
}) })
} }
private argsSanitizer(args: string) {
return args
.split(' ')
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
.filter(Boolean)
}
private async sendHTTP<T>(req: RPCRequest) { private async sendHTTP<T>(req: RPCRequest) {
const res = await fetch(this.httpEndpoint, { const res = await fetch(this.httpEndpoint, {
method: 'POST', method: 'POST',
headers: {
'X-Authentication': this.token ?? ''
},
body: JSON.stringify({ body: JSON.stringify({
...req, ...req,
id: this.incrementSeq(), id: this.incrementSeq(),
@@ -42,33 +64,45 @@ export class RPCClient {
return data return data
} }
public download( public download(req: DownloadRequestArgs) {
url: string, if (!req.url) {
args: string,
pathOverride = '',
renameTo = '',
playlist?: boolean
) {
if (!url) {
return return
} }
if (playlist) {
const rename = req.args.includes('-o')
? req.args
.substring(req.args.indexOf('-o'))
.replaceAll("'", '')
.replaceAll('"', '')
.split('-o')
.map(s => s.trim())
.join('')
.split(' ')
.at(0) ?? ''
: ''
const sanitizedArgs = this.argsSanitizer(
req.args.replace('-o', '').replace(rename, '')
)
if (req.playlist) {
return this.sendHTTP({ return this.sendHTTP({
method: 'Service.ExecPlaylist', method: 'Service.ExecPlaylist',
params: [{ params: [{
URL: url, URL: req.url,
Params: args.split(" ").map(a => a.trim()), Params: sanitizedArgs,
Path: pathOverride, Path: req.pathOverride,
Rename: req.renameTo || rename,
}] }]
}) })
} }
this.sendHTTP({ this.sendHTTP({
method: 'Service.Exec', method: 'Service.Exec',
params: [{ params: [{
URL: url.split("?list").at(0)!, URL: req.url.split('?list').at(0)!,
Params: args.split(" ").map(a => a.trim()), Params: sanitizedArgs,
Path: pathOverride, Path: req.pathOverride,
Rename: renameTo, Rename: req.renameTo || rename,
}] }]
}) })
} }
@@ -78,7 +112,7 @@ export class RPCClient {
return this.sendHTTP<DLMetadata>({ return this.sendHTTP<DLMetadata>({
method: 'Service.Formats', method: 'Service.Formats',
params: [{ params: [{
URL: url.split("?list").at(0)!, URL: url.split('?list').at(0)!,
}] }]
}) })
} }

View File

@@ -1,20 +1,22 @@
import { Alert, Snackbar } from "@mui/material" import { Alert, Snackbar } from "@mui/material"
import { useRecoilState } from 'recoil' import { useRecoilState } from 'recoil'
import { toastListState } from '../atoms/toast' import { Toast, toastListState } from '../atoms/toast'
import { useEffect } from 'react' import { useEffect } from 'react'
const Toaster: React.FC = () => { const Toaster: React.FC = () => {
const [toasts, setToasts] = useRecoilState(toastListState) const [toasts, setToasts] = useRecoilState(toastListState)
const deletePredicate = (t: Toast) => (Date.now() - t.createdAt) < 2000
useEffect(() => { useEffect(() => {
if (toasts.length > 0) { if (toasts.length > 0) {
const closer = setInterval(() => { const closer = setInterval(() => {
setToasts(t => t.map(t => ({ ...t, open: false }))) setToasts(t => t.map(t => ({ ...t, open: deletePredicate(t) })))
}, 2000) }, 900)
const cleaner = setInterval(() => { const cleaner = setInterval(() => {
setToasts(t => t.filter((x) => (Date.now() - x.createdAt) < 2000)) setToasts(t => t.filter(deletePredicate))
}, 2250) }, 2005)
return () => { return () => {
clearInterval(closer) clearInterval(closer)
@@ -29,7 +31,7 @@ const Toaster: React.FC = () => {
<Snackbar <Snackbar
key={index} key={index}
open={toast.open} open={toast.open}
sx={index > 0 ? { marginBottom: index * 6.5 } : {}} sx={index > 0 ? { marginBottom: index * 6.5 } : null}
> >
<Alert variant="filled" severity={toast.severity}> <Alert variant="filled" severity={toast.severity}>
{toast.message} {toast.message}

View File

@@ -57,6 +57,14 @@ export const router = createHashRouter([
</Suspense > </Suspense >
) )
}, },
{
path: '/error',
element: (
<Suspense fallback={<CircularProgress />}>
<ErrorBoundary />
</Suspense >
)
},
] ]
}, },
]) ])

View File

@@ -16,11 +16,11 @@ export type RPCRequest = {
id?: string id?: string
} }
export type RPCResponse<T> = { export type RPCResponse<T> = Readonly<{
result: T result: T
error: number | null error: number | null
id?: string id?: string
} }>
type DownloadInfo = { type DownloadInfo = {
url: string url: string
@@ -41,18 +41,18 @@ type DownloadProgress = {
process_status: number process_status: number
} }
export type RPCResult = { export type RPCResult = Readonly<{
id: string id: string
progress: DownloadProgress progress: DownloadProgress
info: DownloadInfo info: DownloadInfo
} }>
export type RPCParams = { export type RPCParams = {
URL: string URL: string
Params?: string Params?: string
} }
export interface DLMetadata { export type DLMetadata = {
formats: Array<DLFormat> formats: Array<DLFormat>
best: DLFormat best: DLFormat
thumbnail: string thumbnail: string
@@ -83,3 +83,8 @@ export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
export type PlayRequest = Pick<DirectoryEntry, 'path'> export type PlayRequest = Pick<DirectoryEntry, 'path'>
export type CustomTemplate = {
id: string
name: string
content: string
}

View File

@@ -9,6 +9,7 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
IconButton,
List, List,
ListItem, ListItem,
ListItemButton, ListItemButton,
@@ -39,6 +40,8 @@ import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { DeleteRequest, DirectoryEntry } from '../types' import { DeleteRequest, DirectoryEntry } from '../types'
import { base64URLEncode, roundMiB } from '../utils' import { base64URLEncode, roundMiB } from '../utils'
import DownloadIcon from '@mui/icons-material/Download'
export default function Downloaded() { export default function Downloaded() {
const serverAddr = useRecoilValue(serverURL) const serverAddr = useRecoilValue(serverURL)
@@ -66,10 +69,10 @@ export default function Downloaded() {
), ),
matchW( matchW(
(e) => { (e) => {
pushMessage(e) pushMessage(e, 'error')
navigate('/login') navigate('/login')
}, },
(d) => files$.next(d), (d) => files$.next(d ?? []),
) )
)() )()
@@ -87,31 +90,39 @@ export default function Downloaded() {
? ['.', ..._upperLevel].join('/') ? ['.', ..._upperLevel].join('/')
: _upperLevel.join('/') : _upperLevel.join('/')
fetch(`${serverAddr}/archive/downloaded`, { const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ subdir: relpath }) body: JSON.stringify({ subdir: relpath })
}) })
.then(res => res.json())
.then(data => { pipe(
files$.next(sub task,
matchW(
(l) => pushMessage(l, 'error'),
(r) => files$.next(sub
? [{ ? [{
name: '..',
isDirectory: true, isDirectory: true,
isVideo: false,
modTime: '',
name: '..',
path: upperLevel, path: upperLevel,
}, ...data] shaSum: '',
: data size: 0,
}, ...r.filter(f => f.name !== '')]
: r.filter(f => f.name !== '')
) )
}) )
)()
} }
const selectable$ = useMemo(() => files$.pipe( const selectable$ = useMemo(() => files$.pipe(
combineLatestWith(selected$), combineLatestWith(selected$),
map(([data, selected]) => data.map(x => ({ map(([data, selected]) => data.map(x => ({
...x, ...x,
selected: selected.includes(x.name) selected: selected.includes(x.path)
}))), }))),
share() share()
), []) ), [])
@@ -147,7 +158,13 @@ export default function Downloaded() {
const onFileClick = (path: string) => startTransition(() => { const onFileClick = (path: string) => startTransition(() => {
const encoded = base64URLEncode(path) const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}`) window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
})
const downloadFile = (path: string) => startTransition(() => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
}) })
const onFolderClick = (path: string) => startTransition(() => { const onFolderClick = (path: string) => startTransition(() => {
@@ -184,11 +201,20 @@ export default function Downloaded() {
{roundMiB(file.size)} {roundMiB(file.size)}
</Typography> </Typography>
} }
{!file.isDirectory && <Checkbox {!file.isDirectory && <>
edge="end" <IconButton
checked={file.selected} size='small'
onChange={() => addSelected(file.name)} onClick={() => downloadFile(file.path)}
/>} sx={{ marginLeft: 1.5 }}
>
<DownloadIcon />
</IconButton>
<Checkbox
edge="end"
checked={file.selected}
onChange={() => addSelected(file.path)}
/>
</>}
</div> </div>
} }
disablePadding disablePadding

View File

@@ -15,6 +15,10 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
const LoginContainer = styled(Container)({ const LoginContainer = styled(Container)({
display: 'flex', display: 'flex',
@@ -42,13 +46,15 @@ export default function Login() {
const navigate = useNavigate() const navigate = useNavigate()
const { pushMessage } = useToast()
const navigateAndReload = () => { const navigateAndReload = () => {
navigate('/') navigate('/')
window.location.reload() window.location.reload()
} }
const login = async () => { const login = async () => {
const res = await fetch(`${url}/auth/login`, { const task = ffetch<string>(`${url}/auth/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -56,9 +62,23 @@ export default function Login() {
body: JSON.stringify({ body: JSON.stringify({
username, username,
password, password,
}) }),
}) })
res.ok ? navigateAndReload() : setFormHasError(true)
pipe(
task,
matchW(
(error) => {
setFormHasError(true)
pushMessage(error, 'error')
},
(token) => {
console.log(token)
localStorage.setItem('token', token)
navigateAndReload()
}
)
)()
} }
return ( return (

31
go.mod
View File

@@ -3,13 +3,32 @@ module github.com/marcopeocchi/yt-dlp-web-ui
go 1.20 go 1.20
require ( require (
github.com/go-chi/chi/v5 v5.0.10 github.com/go-chi/chi/v5 v5.0.11
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/goccy/go-json v0.10.2 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/golang-jwt/jwt/v5 v5.0.0 github.com/google/uuid v1.5.0
github.com/google/uuid v1.3.1 github.com/gorilla/websocket v1.5.1
github.com/gorilla/websocket v1.5.0
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
golang.org/x/sys v0.13.0 golang.org/x/sys v0.15.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.28.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/tools v0.16.1 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.38.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
) )

68
go.sum
View File

@@ -1,22 +1,62 @@
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.38.0 h1:o4Lpk0zNDSdsjfEXnF1FGXWQ9PDi1NOdWcLP5n13FGo=
modernc.org/libc v1.38.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=

37
main.go
View File

@@ -14,12 +14,14 @@ import (
) )
var ( var (
port int host string
queueSize int port int
configFile string queueSize int
downloadPath string configFile string
downloaderPath string downloadPath string
sessionFilePath string downloaderPath string
sessionFilePath string
localDatabasePath string
requireAuth bool requireAuth bool
username string username string
@@ -35,6 +37,7 @@ var (
func init() { func init() {
flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at")
flag.IntVar(&port, "port", 3033, "Port where server will listen at") flag.IntVar(&port, "port", 3033, "Port where server will listen at")
flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size") flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size")
@@ -42,6 +45,7 @@ func init() {
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path") flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.StringVar(&sessionFilePath, "session", ".", "session file path") flag.StringVar(&sessionFilePath, "session", ".", "session file path")
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication") flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
flag.StringVar(&username, "user", userFromEnv, "Username required for auth") flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
@@ -59,20 +63,21 @@ func main() {
c := config.Instance() c := config.Instance()
c.SetPort(port) c.Host = host
c.QueueSize(queueSize) c.Port = port
c.DownloadPath(downloadPath) c.QueueSize = queueSize
c.DownloaderPath(downloaderPath) c.DownloadPath = downloadPath
c.SessionFilePath(sessionFilePath) c.DownloaderPath = downloaderPath
c.SessionFilePath = sessionFilePath
c.RequireAuth(requireAuth) c.RequireAuth = requireAuth
c.Username(username) c.Username = username
c.Password(password) c.Password = password
// if config file is found it will be merged with the current config struct // if config file is found it will be merged with the current config struct
if _, err := c.LoadFromFile(configFile); err != nil { if err := c.LoadFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, "no config file found") log.Println(cli.BgRed, "config", cli.Reset, "no config file found")
} }
server.RunBlocking(port, frontend) server.RunBlocking(host, port, frontend, localDatabasePath)
} }

View File

@@ -7,9 +7,8 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var lock sync.Mutex type Config struct {
Host string `yaml:"host"`
type serverConfig struct {
Port int `yaml:"port"` Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"` DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"` DownloaderPath string `yaml:"downloaderPath"`
@@ -20,68 +19,29 @@ type serverConfig struct {
SessionFilePath string `yaml:"session_file_path"` SessionFilePath string `yaml:"session_file_path"`
} }
type config struct { var (
cfg serverConfig instance *Config
} instanceOnce sync.Once
)
func (c *config) LoadFromFile(filename string) (serverConfig, error) { func Instance() *Config {
fd, err := os.Open(filename)
if err != nil {
return serverConfig{}, err
}
if err := yaml.NewDecoder(fd).Decode(&c.cfg); err != nil {
return serverConfig{}, err
}
return c.cfg, nil
}
func (c *config) GetConfig() serverConfig {
return c.cfg
}
func (c *config) SetPort(port int) {
c.cfg.Port = port
}
func (c *config) DownloadPath(path string) {
c.cfg.DownloadPath = path
}
func (c *config) DownloaderPath(path string) {
c.cfg.DownloaderPath = path
}
func (c *config) RequireAuth(value bool) {
c.cfg.RequireAuth = value
}
func (c *config) Username(username string) {
c.cfg.Username = username
}
func (c *config) Password(password string) {
c.cfg.Password = password
}
func (c *config) QueueSize(size int) {
c.cfg.QueueSize = size
}
func (c *config) SessionFilePath(path string) {
c.cfg.SessionFilePath = path
}
var instance *config
func Instance() *config {
if instance == nil { if instance == nil {
lock.Lock() instanceOnce.Do(func() {
defer lock.Unlock() instance = &Config{}
if instance == nil { })
instance = &config{serverConfig{}}
}
} }
return instance return instance
} }
func (c *Config) LoadFile(filename string) error {
fd, err := os.Open(filename)
if err != nil {
return err
}
if err := yaml.NewDecoder(fd).Decode(c); err != nil {
return err
}
return nil
}

26
server/dbutils/migrate.go Normal file
View File

@@ -0,0 +1,26 @@
package dbutils
import (
"context"
"database/sql"
)
func AutoMigrate(ctx context.Context, db *sql.DB) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = db.ExecContext(
ctx,
`CREATE TABLE IF NOT EXISTS templates (
id CHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content TEXT NOT NULL
)`,
)
return err
}

View File

@@ -2,6 +2,8 @@ package handlers
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -11,7 +13,6 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/goccy/go-json"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils" "github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
) )
@@ -20,20 +21,19 @@ type DirectoryEntry struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Size int64 `json:"size"` Size int64 `json:"size"`
SHASum string `json:"shaSum"`
ModTime time.Time `json:"modTime"` ModTime time.Time `json:"modTime"`
IsVideo bool `json:"isVideo"` IsVideo bool `json:"isVideo"`
IsDirectory bool `json:"isDirectory"` IsDirectory bool `json:"isDirectory"`
} }
func walkDir(root string) (*[]DirectoryEntry, error) { func walkDir(root string) (*[]DirectoryEntry, error) {
files := []DirectoryEntry{}
dirs, err := os.ReadDir(root) dirs, err := os.ReadDir(root)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var files []DirectoryEntry
for _, d := range dirs { for _, d := range dirs {
if !utils.IsValidEntry(d) { if !utils.IsValidEntry(d) {
continue continue
@@ -50,7 +50,6 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
Path: path, Path: path,
Name: d.Name(), Name: d.Name(),
Size: info.Size(), Size: info.Size(),
SHASum: utils.ShaSumString(path),
IsVideo: utils.IsVideo(d), IsVideo: utils.IsVideo(d),
IsDirectory: d.IsDir(), IsDirectory: d.IsDir(),
ModTime: info.ModTime(), ModTime: info.ModTime(),
@@ -66,11 +65,10 @@ type ListRequest struct {
} }
func ListDownloaded(w http.ResponseWriter, r *http.Request) { func ListDownloaded(w http.ResponseWriter, r *http.Request) {
root := config.Instance().GetConfig().DownloadPath root := config.Instance().DownloadPath
req := new(ListRequest) req := new(ListRequest)
err := json.NewDecoder(r.Body).Decode(&req) if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -88,9 +86,8 @@ func ListDownloaded(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(files)
if err != nil { if err := json.NewEncoder(w).Encode(files); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
@@ -100,21 +97,13 @@ type DeleteRequest = DirectoryEntry
func DeleteFile(w http.ResponseWriter, r *http.Request) { func DeleteFile(w http.ResponseWriter, r *http.Request) {
req := new(DeleteRequest) req := new(DeleteRequest)
err := json.NewDecoder(r.Body).Decode(&req) if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
sum := utils.ShaSumString(req.Path) if err := os.Remove(req.Path); err != nil {
if sum != req.SHASum { http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, "shasum mismatch", http.StatusBadRequest)
return
}
err = os.Remove(req.Path)
if err != nil {
http.Error(w, "shasum mismatch", http.StatusBadRequest)
return return
} }
@@ -142,18 +131,60 @@ func SendFile(w http.ResponseWriter, r *http.Request) {
return return
} }
decodedStr := string(decoded) filename := string(decoded)
root := config.Instance().GetConfig().DownloadPath root := config.Instance().DownloadPath
// TODO: further path / file validations // TODO: further path / file validations
if strings.Contains(filepath.Dir(decodedStr), root) { if strings.Contains(filepath.Dir(filename), root) {
w.Header().Add( http.ServeFile(w, r, filename)
"Content-Disposition", return
"inline; filename="+filepath.Base(decodedStr), }
)
w.WriteHeader(http.StatusUnauthorized)
http.ServeFile(w, r, decodedStr) }
func DownloadFile(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "id")
if path == "" {
http.Error(w, "inexistent path", http.StatusBadRequest)
return
}
path, err := url.QueryUnescape(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
decoded, err := base64.StdEncoding.DecodeString(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filename := string(decoded)
root := config.Instance().DownloadPath
if strings.Contains(filepath.Dir(filename), root) {
w.Header().Add(
"Content-Disposition",
"inline; filename="+filepath.Base(filename),
)
w.Header().Set(
"Content-Type",
"application/octet-stream",
)
fd, err := os.Open(filename)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.Copy(w, fd)
} }
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)

View File

@@ -1,11 +1,11 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"os" "os"
"time" "time"
"github.com/goccy/go-json"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils" "github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
@@ -25,12 +25,12 @@ func Login(w http.ResponseWriter, r *http.Request) {
} }
var ( var (
username = config.Instance().GetConfig().Username username = config.Instance().Username
password = config.Instance().GetConfig().Password password = config.Instance().Password
) )
if username != req.Username || password != req.Password { if username != req.Username || password != req.Password {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, "invalid username or password", http.StatusBadRequest)
return return
} }
@@ -47,16 +47,10 @@ func Login(w http.ResponseWriter, r *http.Request) {
return return
} }
cookie := &http.Cookie{ if err := json.NewEncoder(w).Encode(tokenString); err != nil {
Name: utils.TOKEN_COOKIE_NAME, http.Error(w, err.Error(), http.StatusInternalServerError)
HttpOnly: true, return
Secure: false,
Expires: expiresAt, // 30 days
Value: tokenString,
Path: "/",
} }
http.SetCookie(w, cookie)
} }
func Logout(w http.ResponseWriter, r *http.Request) { func Logout(w http.ResponseWriter, r *http.Request) {

View File

@@ -7,7 +7,7 @@ type DownloadProgress struct {
Status int `json:"process_status"` Status int `json:"process_status"`
Percentage string `json:"percentage"` Percentage string `json:"percentage"`
Speed float32 `json:"speed"` Speed float32 `json:"speed"`
ETA int `json:"eta"` ETA float32 `json:"eta"`
} }
// Used to deser the yt-dlp -J output // Used to deser the yt-dlp -J output
@@ -50,6 +50,8 @@ type ProcessResponse struct {
Id string `json:"id"` Id string `json:"id"`
Progress DownloadProgress `json:"progress"` Progress DownloadProgress `json:"progress"`
Info DownloadInfo `json:"info"` Info DownloadInfo `json:"info"`
Output DownloadOutput `json:"output"`
Params []string `json:"params"`
} }
// struct representing the current status of the memoryDB // struct representing the current status of the memoryDB
@@ -76,3 +78,9 @@ type DownloadRequest struct {
type SetCookiesRequest struct { type SetCookiesRequest struct {
Cookies string `json:"cookies"` Cookies string `json:"cookies"`
} }
type CustomTemplate struct {
Id string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
}

View File

@@ -30,7 +30,7 @@ func (m *MemoryDB) Get(id string) (*Process, error) {
// 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.NewString()
m.table.Store(id, process) m.table.Store(id, process)
process.Id = id process.Id = id
return id return id
@@ -85,6 +85,8 @@ func (m *MemoryDB) All() *[]ProcessResponse {
Id: key.(string), Id: key.(string),
Info: value.(*Process).Info, Info: value.(*Process).Info,
Progress: value.(*Process).Progress, Progress: value.(*Process).Progress,
Output: value.(*Process).Output,
Params: value.(*Process).Params,
}) })
return true return true
}) })
@@ -95,12 +97,9 @@ func (m *MemoryDB) All() *[]ProcessResponse {
func (m *MemoryDB) Persist() { func (m *MemoryDB) Persist() {
running := m.All() running := m.All()
sessionFile := filepath.Join( sf := filepath.Join(config.Instance().SessionFilePath, "session.dat")
config.Instance().GetConfig().SessionFilePath,
"session.dat",
)
fd, err := os.Create(sessionFile) fd, err := os.Create(sf)
if err != nil { if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset) log.Println(cli.Red, "Failed to persist session", cli.Reset)
} }
@@ -137,6 +136,8 @@ func (m *MemoryDB) Restore() {
Url: proc.Info.URL, Url: proc.Info.URL,
Info: proc.Info, Info: proc.Info,
Progress: proc.Progress, Progress: proc.Progress,
Output: proc.Output,
Params: proc.Params,
} }
m.table.Store(proc.Id, restored) m.table.Store(proc.Id, restored)

View File

@@ -16,7 +16,7 @@ type MessageQueue struct {
// CPU cores. // CPU cores.
// The queue size can be set via the qs flag. // The queue size can be set via the qs flag.
func NewMessageQueue() *MessageQueue { func NewMessageQueue() *MessageQueue {
size := config.Instance().GetConfig().QueueSize size := config.Instance().QueueSize
if size <= 0 { if size <= 0 {
log.Fatalln("invalid queue size") log.Fatalln("invalid queue size")

View File

@@ -1,25 +1,27 @@
package internal package internal
import ( import (
"encoding/json"
"errors" "errors"
"log" "log"
"os/exec" "os/exec"
"strings"
"time" "time"
"github.com/goccy/go-json"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
type metadata struct { type metadata struct {
Entries []DownloadInfo `json:"entries"` Entries []DownloadInfo `json:"entries"`
Count int `json:"playlist_count"` Count int `json:"playlist_count"`
Type string `json:"_type"` PlaylistTitle string `json:"title"`
Type string `json:"_type"`
} }
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error { func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
var ( var (
downloader = config.Instance().GetConfig().DownloaderPath downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "-J") cmd = exec.Command(downloader, req.URL, "-J")
) )
@@ -57,12 +59,23 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
for i, meta := range m.Entries { for i, meta := range m.Entries {
delta := time.Second.Microseconds() * int64(i+1) delta := time.Second.Microseconds() * int64(i+1)
// detect playlist title from metadata since each playlist entry will be
// treated as an individual download
req.Rename = strings.Replace(
req.Rename,
"%(playlist_title)s",
m.PlaylistTitle,
1,
)
proc := &Process{ proc := &Process{
Url: meta.OriginalURL, Url: meta.OriginalURL,
Progress: DownloadProgress{}, Progress: DownloadProgress{},
Output: DownloadOutput{}, Output: DownloadOutput{
Info: meta, Filename: req.Rename,
Params: req.Params, },
Info: meta,
Params: req.Params,
} }
proc.Info.URL = meta.OriginalURL proc.Info.URL = meta.OriginalURL

View File

@@ -2,13 +2,12 @@ package internal
import ( import (
"bufio" "bufio"
"encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"sync" "sync"
"syscall" "syscall"
"github.com/goccy/go-json"
"log" "log"
"os" "os"
"os/exec" "os/exec"
@@ -18,6 +17,7 @@ import (
"github.com/marcopeocchi/fazzoletti/slices" "github.com/marcopeocchi/fazzoletti/slices"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
) )
const template = `download: const template = `download:
@@ -38,7 +38,7 @@ type ProgressTemplate struct {
Percentage string `json:"percentage"` Percentage string `json:"percentage"`
Speed float32 `json:"speed"` Speed float32 `json:"speed"`
Size string `json:"size"` Size string `json:"size"`
Eta int `json:"eta"` Eta float32 `json:"eta"`
} }
// Process descriptor // Process descriptor
@@ -70,30 +70,38 @@ func (p *Process) Start() {
return !match return !match
}) })
p.Params = slices.Filter(p.Params, func(e string) bool {
return e != ""
})
out := DownloadOutput{ out := DownloadOutput{
Path: config.Instance().GetConfig().DownloadPath, Path: config.Instance().DownloadPath,
Filename: "%(title)s.%(ext)s", Filename: "%(title)s.%(ext)s",
} }
if p.Output.Path != "" { if p.Output.Path != "" {
out.Path = p.Output.Path out.Path = p.Output.Path
} }
if p.Output.Filename != "" { if p.Output.Filename != "" {
out.Filename = p.Output.Filename + ".%(ext)s" out.Filename = p.Output.Filename
} }
buildFilename(&p.Output)
params := append([]string{ params := append([]string{
strings.Split(p.Url, "?list")[0], //no playlist strings.Split(p.Url, "?list")[0], //no playlist
"--newline", "--newline",
"--no-colors", "--no-colors",
"--no-playlist", "--no-playlist",
"--progress-template", strings.ReplaceAll(template, "\n", ""), "--progress-template",
strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(template),
"-o", "-o",
fmt.Sprintf("%s/%s", out.Path, out.Filename), fmt.Sprintf("%s/%s", out.Path, out.Filename),
}, p.Params...) }, p.Params...)
// ----------------- main block ----------------- // // ----------------- main block ----------------- //
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, params...) cmd := exec.Command(config.Instance().DownloaderPath, params...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
r, err := cmd.StdoutPipe() r, err := cmd.StdoutPipe()
@@ -109,11 +117,11 @@ func (p *Process) Start() {
p.proc = cmd.Process p.proc = cmd.Process
// ----------------- info block ----------------- //
// spawn a goroutine that retrieves the info for the download
// --------------- progress block --------------- // // --------------- progress block --------------- //
// unbuffered channel connected to stdout var (
sourceChan = make(chan []byte)
doneChan = make(chan struct{})
)
// spawn a goroutine that does the dirty job of parsing the stdout // spawn a goroutine that does the dirty job of parsing the stdout
// filling the channel with as many stdout line as yt-dlp produces (producer) // filling the channel with as many stdout line as yt-dlp produces (producer)
@@ -121,11 +129,21 @@ func (p *Process) Start() {
defer func() { defer func() {
r.Close() r.Close()
p.Complete() p.Complete()
doneChan <- struct{}{}
close(sourceChan)
close(doneChan)
}() }()
for scan.Scan() { for scan.Scan() {
sourceChan <- scan.Bytes()
}
}()
// Slows down the unmarshal operation to every 500ms
go func() {
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
stdout := ProgressTemplate{} stdout := ProgressTemplate{}
err := json.Unmarshal(scan.Bytes(), &stdout) err := json.Unmarshal(event, &stdout)
if err == nil { if err == nil {
p.Progress = DownloadProgress{ p.Progress = DownloadProgress{
Status: StatusDownloading, Status: StatusDownloading,
@@ -139,7 +157,7 @@ func (p *Process) Start() {
p.Url, stdout.Percentage, p.Url, stdout.Percentage,
) )
} }
} })
}() }()
// ------------- end progress block ------------- // // ------------- end progress block ------------- //
@@ -188,7 +206,7 @@ func (p *Process) Kill() error {
// Returns the available format for this URL // Returns the available format for this URL
func (p *Process) GetFormatsSync() (DownloadFormats, error) { func (p *Process) GetFormatsSync() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, p.Url, "-J") cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output() stdout, err := cmd.Output()
if err != nil { if err != nil {
@@ -241,7 +259,7 @@ func (p *Process) SetPending() {
} }
func (p *Process) SetMetadata() error { func (p *Process) SetMetadata() error {
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, p.Url, "-J") cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
@@ -282,3 +300,16 @@ func (p *Process) SetMetadata() error {
func (p *Process) getShortId() string { func (p *Process) getShortId() string {
return strings.Split(p.Id, "-")[0] return strings.Split(p.Id, "-")[0]
} }
func buildFilename(o *DownloadOutput) {
if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") {
o.Filename += ".%(ext)s"
}
o.Filename = strings.Replace(
o.Filename,
".%(ext)s.%(ext)s",
".%(ext)s",
1,
)
}

View File

@@ -1,56 +1,53 @@
package middlewares package middlewares
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
) )
func validateToken(tokenValue string) error {
if tokenValue == "" {
return errors.New("invalid token")
}
token, _ := jwt.Parse(tokenValue, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(os.Getenv("JWT_SECRET")), nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
if err != nil {
return err
}
if time.Now().After(expiresAt) {
return errors.New("token expired")
}
} else {
return errors.New("invalid token")
}
return nil
}
func Authenticated(next http.Handler) http.Handler { func Authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !config.Instance().GetConfig().RequireAuth { token := r.Header.Get("X-Authentication")
next.ServeHTTP(w, r) if token == "" {
return token = r.URL.Query().Get("token")
} }
cookie, err := r.Cookie(utils.TOKEN_COOKIE_NAME) if err := validateToken(token); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
if err != nil {
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
if cookie == nil {
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
token, _ := jwt.Parse(cookie.Value, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(os.Getenv("JWT_SECRET")), nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if time.Now().After(expiresAt) {
http.Error(w, "token expired", http.StatusBadRequest)
return
}
} else {
http.Error(w, "invalid token", http.StatusBadRequest)
return return
} }

View File

@@ -1,26 +1,34 @@
package rest package rest
import ( import (
"database/sql"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
) )
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Handler { func Container(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) *Handler {
var ( var (
service = ProvideService(db, mq) service = ProvideService(db, mdb, mq)
handler = ProvideHandler(service) handler = ProvideHandler(service)
) )
return handler return handler
} }
func ApplyRouter(db *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) { func ApplyRouter(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) {
h := Container(db, mq) h := Container(db, mdb, mq)
return func(r chi.Router) { return func(r chi.Router) {
r.Use(middlewares.Authenticated) if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Post("/exec", h.Exec()) r.Post("/exec", h.Exec())
r.Get("/running", h.Running()) r.Get("/running", h.Running())
r.Post("/cookies", h.SetCookies()) r.Post("/cookies", h.SetCookies())
r.Post("/template", h.AddTemplate())
r.Get("/template/all", h.GetTemplates())
r.Delete("/template/{id}", h.DeleteTemplate())
} }
} }

View File

@@ -1,9 +1,10 @@
package rest package rest
import ( import (
"encoding/json"
"net/http" "net/http"
"github.com/goccy/go-json" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
@@ -19,7 +20,7 @@ func (h *Handler) Exec() http.HandlerFunc {
req := internal.DownloadRequest{} req := internal.DownloadRequest{}
if err := json.NewDecoder(r.Body).DecodeContext(r.Context(), &req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
@@ -29,7 +30,7 @@ func (h *Handler) Exec() http.HandlerFunc {
return return
} }
err = json.NewEncoder(w).EncodeContext(r.Context(), id) err = json.NewEncoder(w).Encode(id)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
@@ -48,7 +49,7 @@ func (h *Handler) Running() http.HandlerFunc {
return return
} }
err = json.NewEncoder(w).EncodeContext(r.Context(), res) err = json.NewEncoder(w).Encode(res)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
@@ -63,7 +64,7 @@ func (h *Handler) SetCookies() http.HandlerFunc {
req := new(internal.SetCookiesRequest) req := new(internal.SetCookiesRequest)
err := json.NewDecoder(r.Body).DecodeContext(r.Context(), req) err := json.NewDecoder(r.Body).Decode(req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -75,7 +76,79 @@ func (h *Handler) SetCookies() http.HandlerFunc {
return return
} }
err = json.NewEncoder(w).EncodeContext(r.Context(), "ok") err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) AddTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
req := new(internal.CustomTemplate)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Name == "" || req.Content == "" {
http.Error(w, "Invalid template", http.StatusBadRequest)
return
}
err = h.service.SaveTemplate(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) GetTemplates() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
templates, err := h.service.GetTemplates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(templates)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) DeleteTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
id := chi.URLParam(r, "id")
err := h.service.DeleteTemplate(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }

View File

@@ -1,6 +1,7 @@
package rest package rest
import ( import (
"database/sql"
"sync" "sync"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
@@ -14,11 +15,12 @@ var (
handlerOnce sync.Once handlerOnce sync.Once
) )
func ProvideService(db *internal.MemoryDB, mq *internal.MessageQueue) *Service { func ProvideService(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) *Service {
serviceOnce.Do(func() { serviceOnce.Do(func() {
service = &Service{ service = &Service{
db: db, mdb: mdb,
mq: mq, db: db,
mq: mq,
} }
}) })
return service return service

View File

@@ -2,15 +2,18 @@ package rest
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"os" "os"
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
type Service struct { type Service struct {
db *internal.MemoryDB mdb *internal.MemoryDB
mq *internal.MessageQueue db *sql.DB
mq *internal.MessageQueue
} }
func (s *Service) Exec(req internal.DownloadRequest) (string, error) { func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
@@ -23,7 +26,7 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
}, },
} }
id := s.db.Set(p) id := s.mdb.Set(p)
s.mq.Publish(p) s.mq.Publish(p)
return id, nil return id, nil
@@ -34,7 +37,7 @@ func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, err
case <-ctx.Done(): case <-ctx.Done():
return nil, errors.New("context cancelled") return nil, errors.New("context cancelled")
default: default:
return s.db.All(), nil return s.mdb.All(), nil
} }
} }
@@ -49,3 +52,66 @@ func (s *Service) SetCookies(ctx context.Context, cookies string) error {
return nil return nil
} }
func (s *Service) SaveTemplate(ctx context.Context, template *internal.CustomTemplate) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(
ctx,
"INSERT INTO templates (id, name, content) VALUES (?, ?, ?)",
uuid.NewString(),
template.Name,
template.Content,
)
return err
}
func (s *Service) GetTemplates(ctx context.Context) (*[]internal.CustomTemplate, error) {
conn, err := s.db.Conn(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
rows, err := conn.QueryContext(ctx, "SELECT * FROM templates")
if err != nil {
return nil, err
}
defer rows.Close()
templates := make([]internal.CustomTemplate, 0)
for rows.Next() {
t := internal.CustomTemplate{}
err := rows.Scan(&t.Id, &t.Name, &t.Content)
if err != nil {
return nil, err
}
templates = append(templates, t)
}
return &templates, nil
}
func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(ctx, "DELETE FROM templates WHERE id = ?", id)
return err
}

View File

@@ -2,6 +2,7 @@ package rpc
import ( import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
) )
@@ -17,7 +18,9 @@ func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
// RPC service must be registered before applying this router! // RPC service must be registered before applying this router!
func ApplyRouter() func(chi.Router) { func ApplyRouter() func(chi.Router) {
return func(r chi.Router) { return func(r chi.Router) {
r.Use(middlewares.Authenticated) if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Get("/ws", WebSocket) r.Get("/ws", WebSocket)
r.Post("/http", Post) r.Post("/http", Post)
} }

View File

@@ -2,6 +2,7 @@ package rpc
import ( import (
"io" "io"
"log"
"net/http" "net/http"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -29,6 +30,7 @@ func WebSocket(w http.ResponseWriter, r *http.Request) {
mtype, reader, err := c.NextReader() mtype, reader, err := c.NextReader()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println(err)
break break
} }
@@ -37,6 +39,7 @@ func WebSocket(w http.ResponseWriter, r *http.Request) {
writer, err := c.NextWriter(mtype) writer, err := c.NextWriter(mtype)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println(err)
break break
} }

View File

@@ -2,47 +2,27 @@ package rx
import "time" import "time"
/*
Package rx contains:
- Definitions for common reactive programming functions/patterns
*/
// ReactiveX inspired debounce function.
//
// Debounce emits a string from the source channel only after a particular
// time span determined a Go Interval
//
// --A--B--CD--EFG-------|>
//
// -t-> |>
// -t-> |> t is a timer tick
// -t-> |>
//
// --A-----C-----G-------|>
func Debounce(interval time.Duration, source chan string, f func(emit string)) {
var item string
timer := time.NewTimer(interval)
for {
select {
case item = <-source:
timer.Reset(interval)
case <-timer.C:
if item != "" {
f(item)
}
}
}
}
// ReactiveX inspired sample function. // ReactiveX inspired sample function.
// //
// Debounce emits the most recently emitted value from the source // Debounce emits the most recently emitted value from the source
// withing the timespan set by the span time.Duration // withing the timespan set by the span time.Duration
func Sample[T any](span time.Duration, source chan T, cb func(emit T)) { func Sample(span time.Duration, source chan []byte, done chan struct{}, fn func(e []byte)) {
timer := time.NewTimer(span) var (
item []byte
ticker = time.NewTicker(span)
)
for { for {
<-timer.C select {
cb(<-source) case <-ticker.C:
timer.Reset(span) if item != nil {
fn(item)
}
case <-source:
item = <-source
case <-done:
ticker.Stop()
return
}
} }
} }

View File

@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"io/fs" "io/fs"
"log" "log"
@@ -15,47 +16,79 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/dbutils"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers" "github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest" "github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc" ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
_ "modernc.org/sqlite"
) )
type serverConfig struct { type serverConfig struct {
frontend fs.FS frontend fs.FS
host string
port int port int
db *internal.MemoryDB mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue mq *internal.MessageQueue
} }
func RunBlocking(port int, frontend fs.FS) { func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
var db internal.MemoryDB var mdb internal.MemoryDB
db.Restore() mdb.Restore()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatalln(err)
}
err = dbutils.AutoMigrate(context.Background(), db)
if err != nil {
log.Fatalln(err)
}
mq := internal.NewMessageQueue() mq := internal.NewMessageQueue()
go mq.Subscriber() go mq.Subscriber()
srv := newServer(serverConfig{ srv := newServer(serverConfig{
frontend: frontend, frontend: frontend,
host: host,
port: port, port: port,
db: &db, mdb: &mdb,
mq: mq, mq: mq,
db: db,
}) })
go gracefulShutdown(srv, &db) go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &db) go autoPersist(time.Minute*5, &mdb)
log.Fatal(srv.ListenAndServe()) log.Fatal(srv.ListenAndServe())
} }
func newServer(c serverConfig) *http.Server { func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.db, c.mq) service := ytdlpRPC.Container(c.mdb, c.mq)
rpc.Register(service) rpc.Register(service)
r := chi.NewRouter() r := chi.NewRouter()
r.Use(cors.AllowAll().Handler) corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
})
r.Use(corsMiddleware.Handler)
r.Use(middleware.Logger) r.Use(middleware.Logger)
app := http.FileServer(http.FS(c.frontend)) app := http.FileServer(http.FS(c.frontend))
@@ -64,10 +97,13 @@ func newServer(c serverConfig) *http.Server {
// Archive routes // Archive routes
r.Route("/archive", func(r chi.Router) { r.Route("/archive", func(r chi.Router) {
r.Use(middlewares.Authenticated) if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Post("/downloaded", handlers.ListDownloaded) r.Post("/downloaded", handlers.ListDownloaded)
r.Post("/delete", handlers.DeleteFile) r.Post("/delete", handlers.DeleteFile)
r.Get("/d/{id}", handlers.SendFile) r.Get("/d/{id}", handlers.DownloadFile)
r.Get("/v/{id}", handlers.SendFile)
}) })
// Authentication routes // Authentication routes
@@ -80,10 +116,10 @@ func newServer(c serverConfig) *http.Server {
r.Route("/rpc", ytdlpRPC.ApplyRouter()) r.Route("/rpc", ytdlpRPC.ApplyRouter())
// REST API handlers // REST API handlers
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mq)) r.Route("/api/v1", rest.ApplyRouter(c.db, c.mdb, c.mq))
return &http.Server{ return &http.Server{
Addr: fmt.Sprintf(":%d", c.port), Addr: fmt.Sprintf("%s:%d", c.host, c.port),
Handler: r, Handler: r,
} }
} }

View File

@@ -14,7 +14,7 @@ import (
// FreeSpace gets the available Bytes writable to download directory // FreeSpace gets the available Bytes writable to download directory
func FreeSpace() (uint64, error) { func FreeSpace() (uint64, error) {
var stat unix.Statfs_t var stat unix.Statfs_t
unix.Statfs(config.Instance().GetConfig().DownloadPath, &stat) unix.Statfs(config.Instance().DownloadPath, &stat)
return (stat.Bavail * uint64(stat.Bsize)), nil return (stat.Bavail * uint64(stat.Bsize)), nil
} }
@@ -26,7 +26,7 @@ func DirectoryTree() (*[]string, error) {
children []Node children []Node
} }
rootPath := config.Instance().GetConfig().DownloadPath rootPath := config.Instance().DownloadPath
stack := internal.NewStack[Node]() stack := internal.NewStack[Node]()
flattened := make([]string, 0) flattened := make([]string, 0)

View File

@@ -8,7 +8,7 @@ import (
// Update using the builtin function of yt-dlp // Update using the builtin function of yt-dlp
func UpdateExecutable() error { func UpdateExecutable() error {
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, "-U") cmd := exec.Command(config.Instance().DownloaderPath, "-U")
err := cmd.Start() err := cmd.Start()
if err != nil { if err != nil {