Compare commits

..

61 Commits

Author SHA1 Message Date
589468ed0e defined grpc proto3 file 2024-06-04 11:22:26 +02:00
7c86e1dd23 updated Makefile 2024-06-04 11:21:54 +02:00
ed79e70ee3 fixed duplicate store key 2024-06-04 11:21:07 +02:00
8efa72c964 code refactoring 2024-06-04 11:04:48 +02:00
d4a35f1d1d Support for reverse proxy subdir.
Closes #110 #150
2024-06-04 10:49:55 +02:00
4013a66b04 stream downloads zip archive 2024-06-03 11:03:16 +02:00
4cc1ed681a fix ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION
closes #154
2024-06-03 10:52:05 +02:00
Marco Piovanello
306e673f59 Update README.md 2024-05-24 15:55:11 +02:00
22e80893f3 swagger 2024-05-24 15:53:11 +02:00
f2389a6e6a Fixed nil logger pointer in rest endpoints
Closes #153
2024-05-24 13:59:03 +02:00
Jordy
e0e923822c Add docker compose example (#144)
* Add docker compose example

* compose: healthcheck and restart policy

* Update README.md
2024-05-20 09:17:17 +02:00
46926eb873 removed utils package 2024-05-20 08:59:39 +02:00
472db89ea3 load balancer implementation, code refactoring 2024-05-20 08:48:01 +02:00
Jakub Tarnawski
fee7f86182 Update README.md (#149)
Adding some hopefully helpful hints on running yt-dlp-webui as a systemd daemon
2024-05-18 21:31:29 +02:00
Marco Piovanello
1da32f3c65 Update issue templates 2024-05-16 11:42:28 +02:00
c10f60d4d4 async metadata provider 2024-05-16 11:05:08 +02:00
da84eb14f3 changed channel based approach to sync/semaphore 2024-05-16 10:39:29 +02:00
f5f0af7e1e fixed potential goroutine deadlock in message queue 2024-05-16 10:10:42 +02:00
Tobias
ace6621d4a Corrected a Typo (#148) 2024-05-16 10:02:56 +02:00
e0202ff631 restored original queue size 2024-05-14 11:47:32 +02:00
8f10baf09b changed to an "event bus" approach in the message queue, fixed unset download parameters. 2024-05-14 11:21:02 +02:00
62eadb52a6 use message queue to restore download from session file 2024-05-13 11:15:08 +02:00
6abfb57598 fixed playlist download not setting metadata 2024-05-13 11:04:16 +02:00
dc2828b884 re-added gnu wget to base container 2024-05-10 11:50:27 +02:00
d58a2e6692 dependecies update 2024-05-10 11:50:10 +02:00
00bacf5c41 comments and code refactoring 2024-04-24 11:52:14 +02:00
01c327d308 fix nil pointer 2024-04-24 11:10:42 +02:00
83f6444df2 migrated from alpine to wolfi 2024-04-24 11:02:12 +02:00
e09c52dd05 migrated from alpine to wolfi 2024-04-24 10:49:46 +02:00
3da81affb3 better error logging 2024-04-22 10:03:16 +02:00
a73b8d362e fix broken alpine:edge yt-dlp build 2024-04-18 11:05:44 +02:00
205f2e5cdf Implemented "download file" in dashboard and bulk download
closes #115
2024-04-16 11:27:47 +02:00
294ad29bf2 Fixed possible nil logger in playlist download
Fixes #145
2024-04-12 22:35:05 +02:00
d336b92e46 code refactoring 2024-04-10 12:52:17 +02:00
2f02293a52 code refactoring 2024-04-10 12:02:04 +02:00
566f0f2ac2 download process: added optimistic update. 2024-04-09 09:46:01 +02:00
15ab37de11 code refactoring 2024-03-26 11:34:49 +01:00
Marco Piovanello
f2fab66626 fixed cumulative download bug 2024-03-26 11:24:00 +01:00
Marco Piovanello
86db8176ff Update README.md 2024-03-26 11:05:51 +01:00
1b8d2e0da6 show cumulative download speed
code refactoring
2024-03-26 10:58:03 +01:00
c6e48f4baa code and layout refactoring 2024-03-26 10:10:27 +01:00
82ccb68a56 Layout refactoring, dependencies update 2024-03-25 11:34:40 +01:00
bf2e24009e enabled file logging with log rotation 2024-03-25 11:32:11 +01:00
Boris Rybalkin
e7639c2720 unix socket support (useful behind nginx proxy) (#143) 2024-03-25 09:21:22 +01:00
Marco Piovanello
02832f9de4 code refactoring 2024-03-24 10:50:18 +01:00
29dfebe48b removed 3rd party dependency in favor of std "slices" package
dependencies update
2024-03-24 09:22:17 +01:00
Marco
52862156b9 updated VersionIndicator.tsx 2024-03-22 14:15:50 +01:00
Marco
193ac9f043 Update cosign 2024-03-22 14:02:06 +01:00
Marco
6e4dff5f3a Update README.md 2024-03-22 13:52:17 +01:00
371704db57 ui refactoring 2024-03-22 13:32:08 +01:00
43e5c94b58 display yt-dlp version, multiple downloads enabled.
code refactoring
preparations for optimistic ui updates for new downloads
2024-03-22 13:22:38 +01:00
48c9258088 code and layout refactoring 2024-03-21 10:19:09 +01:00
Marco
87956a6aad Update Makefile 2024-03-18 17:29:12 +01:00
d4305bb2f8 re-enabled armv6 builds
code refactoring
2024-03-18 10:27:40 +01:00
3f836d0fa6 added some comments on the server side 2024-03-18 10:19:39 +01:00
b45107c94b Fixed observable logger, added build stage for frontend
dependencies update

closes #131
2024-03-14 11:59:33 +01:00
0x6d61726b
9cf1a3bc7e removed duplicate/unused file (used in 'frontend/src/assets/', relates to #139) (#140) 2024-03-04 14:40:06 +01:00
df3522fcb3 fixed favicon not showing 2024-03-03 22:32:44 +01:00
e2c27c3857 Added favicon
Closes #139
2024-03-03 20:33:35 +01:00
0x6d61726b
51bcd82ea7 Fixed human-readable file size representation (#137)
(as it follows units of IEC 60027-2 A.2 )
2024-03-03 15:48:56 +01:00
0x6d61726b
f763b9657f Extended config.yml example (#136) 2024-03-03 15:47:52 +01:00
116 changed files with 3227 additions and 8508 deletions

View File

@@ -1,18 +0,0 @@
dist
package-lock.json
pnpm-lock.yaml
.pnpm-debug.log
node_modules
.env
*.mp4
*.ytdl
*.part
*.db
downloads
.DS_Store
build/
yt-dlp-webui
session.dat
config.yml
cookies.txt
examples/

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help improve the project
title: ''
labels: ''
assignees: ''
---
**Version running**
Provide the docker label or release tag you're running.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@@ -25,7 +25,7 @@ jobs:
# v3.1.2 # v3.1.2
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19
with: with:
cosign-release: 'v1.13.1' cosign-release: 'v1.13.6'
- name: Set up QEMU for ARM emulation - name: Set up QEMU for ARM emulation
# v2.2.0 # v2.2.0

3
.gitignore vendored
View File

@@ -14,4 +14,5 @@ session.dat
config.yml config.yml
cookies.txt cookies.txt
__debug* __debug*
app/ ui/
.idea

View File

@@ -1,26 +1,40 @@
FROM golang:alpine AS build # Node (pnpm) ------------------------------------------------------------------
FROM node:20-slim AS ui
RUN apk update && \ ENV PNPM_HOME="/pnpm"
apk add nodejs npm ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
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 rm -rf node_modules
RUN npm run build
RUN pnpm install
RUN pnpm run build
# -----------------------------------------------------------------------------
# Go --------------------------------------------------------------------------
FROM golang AS build
WORKDIR /usr/src/yt-dlp-webui WORKDIR /usr/src/yt-dlp-webui
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
FROM alpine:edge COPY . .
COPY --from=ui /usr/src/yt-dlp-webui/frontend /usr/src/yt-dlp-webui/frontend
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# -----------------------------------------------------------------------------
# dependencies ----------------------------------------------------------------
FROM cgr.dev/chainguard/wolfi-base
RUN apk update && \
apk add ffmpeg ca-certificates python3 py3-pip wget
VOLUME /downloads /config VOLUME /downloads /config
WORKDIR /app RUN python3 -m pip install yt-dlp
RUN apk update && \ WORKDIR /app
apk add psmisc ffmpeg yt-dlp --no-cache
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app

View File

@@ -1,15 +1,22 @@
.PHONY : fe clean all
default: default:
CGO_ENABLED=0 go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
fe:
cd frontend && pnpm build
all: all:
cd frontend && pnpm build && cd .. $(MAKE) fe && cd ..
CGO_ENABLED=0 go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
multiarch: multiarch:
$(MAKE) fe
mkdir -p build mkdir -p build
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -o build/yt-dlp-webui_linux-arm main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go
CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv6 main.go
CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv7 main.go
clean: clean:
rm -rf build rm -rf build

View File

@@ -23,7 +23,9 @@ docker pull marcobaobao/yt-dlp-webui
# latest dev # latest dev
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
``` ```
![output](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/82bcecaf-4ced-441f-9384-105653abfae4)
[app.webm](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964)
![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/a32fbdaa-b033-4aed-b914-a66701ace0ce) ![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/a32fbdaa-b033-4aed-b914-a66701ace0ce)
![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/782c559a-f552-40be-a6fd-10e22f38e85d) ![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/782c559a-f552-40be-a6fd-10e22f38e85d)
@@ -90,6 +92,7 @@ To enable it just go to the settings page and enable the **Enable video/audio fo
- As before server address is not specified or simply yt-dlp process takes a lot of time to fire up. (Forking yt-dlp isn't fast especially if you have a lower-end/low-power NAS/server/desktop where the server is running) - As before server address is not specified or simply yt-dlp process takes a lot of time to fire up. (Forking yt-dlp isn't fast especially if you have a lower-end/low-power NAS/server/desktop where the server is running)
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation ## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
## Docker run
```sh ```sh
docker pull marcobaobao/yt-dlp-webui docker pull marcobaobao/yt-dlp-webui
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
@@ -133,6 +136,20 @@ docker run -d \
--qs 2 --qs 2
``` ```
### Docker Compose
```yaml
services:
yt-dlp-webui:
image: marcobaobao/yt-dlp-webui
ports:
- 3033:3033
volumes:
- <your dir>:/downloads # replace <your dir> with a directory on your host system
healthcheck:
test: curl -f http://localhost:3033 || exit 1
restart: unless-stopped
```
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation ## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
```sh ```sh
@@ -180,16 +197,31 @@ The config file **will overwrite what have been passed as cli argument**.
# Simple configuration file for yt-dlp webui # Simple configuration file for yt-dlp webui
--- ---
port: 8989 # Host where server will listen at (default: "0.0.0.0")
downloadPath: /home/ren/archive #host: 0.0.0.0
downloaderPath: /usr/local/bin/yt-dlp
# Optional settings # Port where server will listen at (default: 3033)
port: 8989
# Directory where downloaded files will be stored (default: ".")
downloadPath: /home/ren/archive
# [optional] Enable RPC authentication (requires username and password)
require_auth: true require_auth: true
username: my_username username: my_username
password: my_random_secret password: my_random_secret
# [optional] The download queue size (default: 8)
queue_size: 4 queue_size: 4
# [optional] Full path to the yt-dlp (default: "yt-dlp")
downloaderPath: /usr/local/bin/yt-dlp
# [optional] Directory where the log file will be stored (default: ".")
#log_path: .
# [optional] Directory where the session database file will be stored (default: ".")
#session_file_path: .
``` ```
### Systemd integration ### Systemd integration
@@ -212,6 +244,29 @@ WantedBy=multi-user.target
systemctl enable yt-dlp-webui systemctl enable yt-dlp-webui
systemctl start yt-dlp-webui systemctl start yt-dlp-webui
``` ```
It could be that yt-dlp-webui works correctly when started manually from the console, but with systemd, it does not see the yt-dlp executable, or has issues writing to the database file. One way to fix these issues could be as follows:
```shell
cd
mkdir yt-dlp-webui-workingdir
# optionally move the already existing database file there:
mv local.db yt-dlp-webui-workingdir
nano yt-dlp-webui-workingdir/my.conf
```
The config file format is described above; make sure to include the `downloaderPath` setting (the path can possibly be found by running `which yt-dlp`). For example, one could have:
```
downloadPath: /stuff/media
downloaderPath: /home/your_user/.local/bin/yt-dlp
log_path: /home/your_user/yt-dlp-webui-workingdir
session_file_path: /home/your_user/yt-dlp-webui-workingdir
```
Adjust the Service section in the `/etc/systemd/system/yt-dlp-webui.service` file as follows:
```
[Service]
User=your_user
Group=your_user
WorkingDirectory=/home/your_user/yt-dlp-webui-workingdir
ExecStart=/usr/local/bin/yt-dlp-webui --conf /home/your_user/yt-dlp-webui-workingdir/my.conf
```
## Manual installation ## Manual installation
```sh ```sh
@@ -223,6 +278,9 @@ npm run build
go build -o yt-dlp-webui main.go go build -o yt-dlp-webui main.go
``` ```
## Open-API
Navigate to `/openapi` to see the related swagger.
## Extendable ## Extendable
You dont'like the Material feel? You dont'like the Material feel?

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
yt-dlp-webui:
image: marcobaobao/yt-dlp-webui
ports:
- 3033:3033
volumes:
- <your dir>:/downloads # replace <your dir> with a directory on your host system
healthcheck:
test: curl -f http://localhost:3033 || exit 1
restart: unless-stopped

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="./src/assets/favicon.ico">
<title>yt-dlp Web UI</title> <title>yt-dlp Web UI</title>
</head> </head>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "2.10.0", "version": "3.0.8",
"description": "Frontend compontent of yt-dlp-webui", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,29 +10,29 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.5",
"@fontsource/roboto": "^5.0.8", "@fontsource/roboto": "^5.0.13",
"@fontsource/roboto-mono": "^5.0.16", "@fontsource/roboto-mono": "^5.0.18",
"@mui/icons-material": "^5.15.4", "@mui/icons-material": "^5.15.16",
"@mui/material": "^5.15.4", "@mui/material": "^5.15.16",
"fp-ts": "^2.16.2", "fp-ts": "^2.16.5",
"million": "^2.6.4", "react": "^18.3.1",
"react": "^18.2.0", "react-dom": "^18.3.1",
"react-dom": "^18.2.0", "react-router-dom": "^6.23.0",
"react-router-dom": "^6.21.2",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/node": "^20.11.4", "@types/node": "^20.11.4",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/react-helmet": "^6.1.11", "@types/react-helmet": "^6.1.11",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.6.0",
"typescript": "^5.3.3", "typescript": "^5.4.3",
"vite": "^5.0.11" "vite": "^5.2.11",
"million": "^3.0.6"
} }
} }

2651
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
import { ThemeProvider } from '@emotion/react' import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard' import Dashboard from '@mui/icons-material/Dashboard'
import DownloadIcon from '@mui/icons-material/Download'
import Menu from '@mui/icons-material/Menu' import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings' import SettingsIcon from '@mui/icons-material/Settings'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet' import TerminalIcon from '@mui/icons-material/Terminal'
import { Box, createTheme } from '@mui/material' import { Box, createTheme } from '@mui/material'
import CssBaseline from '@mui/material/CssBaseline' import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
@@ -16,26 +16,23 @@ 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 { Suspense, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
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'
import { connectedState } from './atoms/status'
import AppBar from './components/AppBar' import AppBar from './components/AppBar'
import Drawer from './components/Drawer' import Drawer from './components/Drawer'
import FreeSpaceIndicator from './components/FreeSpaceIndicator' import Footer from './components/Footer'
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 { useI18n } from './hooks/useI18n'
import Toaster from './providers/ToasterProvider' import Toaster from './providers/ToasterProvider'
import TerminalIcon from '@mui/icons-material/Terminal'
export default function Layout() { export default function Layout() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const settings = useRecoilValue(settingsState) const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState)
const mode = settings.theme const mode = settings.theme
const theme = useMemo(() => const theme = useMemo(() =>
@@ -81,21 +78,6 @@ export default function Layout() {
> >
{settings.appTitle} {settings.appTitle}
</Typography> </Typography>
<Suspense fallback={i18n.t('loadingLabel')}>
<FreeSpaceIndicator />
</Suspense>
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
marginLeft: '4px',
gap: 3,
}}>
<SettingsEthernet />
<span>
{isConnected ? settings.serverAddr : i18n.t('notConnectedText')}
</span>
</div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer variant="permanent" open={open}> <Drawer variant="permanent" open={open}>
@@ -134,7 +116,7 @@ export default function Layout() {
}> }>
<ListItemButton> <ListItemButton>
<ListItemIcon> <ListItemIcon>
<DownloadIcon /> <ArchiveIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} /> <ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton> </ListItemButton>
@@ -181,6 +163,7 @@ export default function Layout() {
<Outlet /> <Outlet />
</Box> </Box>
</Box> </Box>
<Footer />
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>
) )

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,7 +1,7 @@
--- ---
languages: languages:
english: english:
urlInput: Video URL urlInput: Video URL (one per line)
statusTitle: Status statusTitle: Status
statusReady: Ready statusReady: Ready
selectFormatButton: Select format selectFormatButton: Select format
@@ -34,7 +34,8 @@ languages:
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
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
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
newDownloadButton: New download newDownloadButton: New download
homeButtonLabel: Home homeButtonLabel: Home
archiveButtonLabel: Archive archiveButtonLabel: Archive
@@ -49,6 +50,7 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
german: german:
urlInput: Video URL urlInput: Video URL
statusTitle: Status statusTitle: Status
@@ -98,6 +100,7 @@ languages:
templatesEditorContentLabel: Vorlagen Inhalt templatesEditorContentLabel: Vorlagen Inhalt
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
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
@@ -149,8 +152,9 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
italian: italian:
urlInput: URL Video urlInput: URL Video (uno per linea)
statusTitle: Stato statusTitle: Stato
startButton: Inizia startButton: Inizia
statusReady: Pronto statusReady: Pronto
@@ -197,6 +201,7 @@ languages:
templatesEditorContentLabel: Contentunto template templatesEditorContentLabel: Contentunto template
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
chinese: chinese:
urlInput: 视频 URL urlInput: 视频 URL
statusTitle: 状态 statusTitle: 状态
@@ -246,6 +251,7 @@ languages:
templatesEditorContentLabel: 模板内容 templatesEditorContentLabel: 模板内容
logsTitle: '日志' logsTitle: '日志'
awaitingLogs: '正在等待日志…' awaitingLogs: '正在等待日志…'
bulkDownload: 'Download files in a zip archive'
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -293,6 +299,7 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
russian: russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус statusTitle: Статус
@@ -340,6 +347,7 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -387,6 +395,7 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -435,6 +444,7 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
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
@@ -482,6 +492,7 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
ukrainian: ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус statusTitle: Статус
@@ -529,6 +540,7 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
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
@@ -576,3 +588,4 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'

View File

@@ -1,7 +0,0 @@
import { atom } from 'recoil'
import { DLMetadata } from '../types'
export const selectedFormatState = atom<Partial<DLMetadata>>({
key: 'selectedFormatState',
default: {},
})

View File

@@ -135,6 +135,15 @@ export const servedFromReverseProxyState = atom({
] ]
}) })
export const servedFromReverseProxySubDirState = atom<string>({
key: 'servedFromReverseProxySubDirState',
default: localStorage.getItem('reverseProxySubDir') ?? '',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxySubDir', a))
]
})
export const appTitleState = atom({ export const appTitleState = atom({
key: 'appTitleState', key: 'appTitleState',
default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI', default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI',
@@ -146,9 +155,15 @@ export const appTitleState = atom({
export const serverAddressAndPortState = selector({ export const serverAddressAndPortState = selector({
key: 'serverAddressAndPortState', key: 'serverAddressAndPortState',
get: ({ get }) => get(servedFromReverseProxyState) get: ({ get }) => {
? `${get(serverAddressState)}` if (get(servedFromReverseProxySubDirState)) {
: `${get(serverAddressState)}:${get(serverPortState)}` return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
}
if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}`
}
return `${get(serverAddressState)}:${get(serverPortState)}`
}
}) })
export const serverURL = selector({ export const serverURL = selector({

View File

@@ -1,13 +1,6 @@
import { atom, selector } from 'recoil' import { atom, selector } from 'recoil'
import { rpcClientState } from './rpc' import { rpcClientState } from './rpc'
type StatusState = {
connected: boolean,
updated: boolean,
downloading: boolean,
}
export const connectedState = atom({ export const connectedState = atom({
key: 'connectedState', key: 'connectedState',
default: false default: false

View File

@@ -1,6 +1,14 @@
import { atom } from 'recoil' import { atom, selector } from 'recoil'
import { activeDownloadsState } from './downloads'
export const loadingAtom = atom({ export const loadingAtom = atom({
key: 'loadingAtom', key: 'loadingAtom',
default: true default: true
}) })
export const totalDownloadSpeedState = selector<number>({
key: 'totalDownloadSpeedState',
get: ({ get }) => get(activeDownloadsState)
.map(d => d.progress.speed)
.reduce((curr, next) => curr + next, 0)
})

View File

@@ -16,8 +16,10 @@ import {
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { RPCResult } from '../types' import { RPCResult } from '../types'
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils' import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
type Props = { type Props = {
download: RPCResult download: RPCResult
@@ -35,6 +37,8 @@ const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
} }
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => { const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
const serverAddr = useRecoilValue(serverURL)
const isCompleted = useCallback( const isCompleted = useCallback(
() => download.progress.percentage === '-1', () => download.progress.percentage === '-1',
[download.progress.percentage] [download.progress.percentage]
@@ -47,6 +51,16 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
[download.progress.percentage, isCompleted] [download.progress.percentage, isCompleted]
) )
const viewFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
}
const downloadFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
}
return ( return (
<Card> <Card>
<CardActionArea onClick={() => { <CardActionArea onClick={() => {
@@ -61,14 +75,22 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
/> : /> :
<Skeleton variant="rectangular" height={180} /> <Skeleton variant="rectangular" height={180} />
} }
{download.progress.percentage ?
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted() ? "success" : "primary"}
/> :
null
}
<CardContent> <CardContent>
{download.info.title !== '' ? {download.info.title !== '' ?
<Typography gutterBottom variant="h6" component="div"> <Typography gutterBottom variant="h6" component="div">
{ellipsis(download.info.title, 54)} {ellipsis(download.info.title, 100)}
</Typography> : </Typography> :
<Skeleton /> <Skeleton />
} }
<Stack direction="row" spacing={1} py={2}> <Stack direction="row" spacing={0.5} py={1}>
<Chip <Chip
label={ label={
isCompleted() isCompleted()
@@ -86,18 +108,10 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
{!isCompleted() ? formatSpeedMiB(download.progress.speed) : ''} {!isCompleted() ? formatSpeedMiB(download.progress.speed) : ''}
</Typography> </Typography>
<Typography> <Typography>
{roundMiB(download.info.filesize_approx ?? 0)} {formatSize(download.info.filesize_approx ?? 0)}
</Typography> </Typography>
<Resolution resolution={download.info.resolution} /> <Resolution resolution={download.info.resolution} />
</Stack> </Stack>
{download.progress.percentage ?
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted() ? "secondary" : "primary"}
/> :
null
}
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
<CardActions> <CardActions>
@@ -109,6 +123,26 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
> >
{isCompleted() ? "Clear" : "Stop"} {isCompleted() ? "Clear" : "Stop"}
</Button> </Button>
{isCompleted() &&
<>
<Button
variant="contained"
size="small"
color="primary"
onClick={() => downloadFile(download.output.savedFilePath)}
>
Download
</Button>
<Button
variant="contained"
size="small"
color="primary"
onClick={() => viewFile(download.output.savedFilePath)}
>
View
</Button>
</>
}
</CardActions> </CardActions>
</Card> </Card>
) )

View File

@@ -40,7 +40,7 @@ import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' 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 { toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions' import ExtraDownloadOptions from './ExtraDownloadOptions'
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
@@ -80,7 +80,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
) )
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [workingUrl, setWorkingUrl] = useState('')
const [isPlaylist, setIsPlaylist] = useState(false) const [isPlaylist, setIsPlaylist] = useState(false)
@@ -103,35 +102,36 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
/** /**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket * Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
*/ */
const sendUrl = (immediate?: string) => { const sendUrl = async (immediate?: string) => {
const codes = new Array<string>() for (const line of url.split('\n')) {
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat) const codes = new Array<string>()
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat) if (pickedVideoFormat !== '') codes.push(pickedVideoFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat) if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
client.download({ await new Promise(r => setTimeout(r, 10))
url: immediate || url || workingUrl, await client.download({
args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`, url: immediate || line,
pathOverride: downloadPath ?? '', args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
renameTo: settings.fileRenaming ? filenameTemplate : '', pathOverride: downloadPath ?? '',
playlist: isPlaylist, renameTo: settings.fileRenaming ? filenameTemplate : '',
}) playlist: isPlaylist,
})
setTimeout(() => {
resetInput()
setDownloadFormats(undefined)
onDownloadStart(immediate || line)
}, 250)
}
setUrl('') setUrl('')
setWorkingUrl('')
setTimeout(() => {
resetInput()
setDownloadFormats(undefined)
onDownloadStart(immediate || url || workingUrl)
}, 250)
} }
/** /**
* Retrive url from input and display the formats selection view * Retrive url from input and display the formats selection view
*/ */
const sendUrlFormatSelection = () => { const sendUrlFormatSelection = () => {
setWorkingUrl(url)
setUrl('') setUrl('')
setPickedAudioFormat('') setPickedAudioFormat('')
setPickedVideoFormat('') setPickedVideoFormat('')
@@ -166,7 +166,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
file file
.split('\n') .split('\n')
.filter(u => isValidURL(u))
.forEach(u => sendUrl(u)) .forEach(u => sendUrl(u))
} }
@@ -207,7 +206,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
backgroundColor: (theme) => theme.palette.background.default, backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)` minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}> }}>
<Container sx={{ my: 4 }} > <Container sx={{ my: 4 }}>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12}> <Grid item xs={12}>
<Paper <Paper
@@ -220,6 +219,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
> >
<Grid container> <Grid container>
<TextField <TextField
multiline
fullWidth fullWidth
ref={urlInputRef} ref={urlInputRef}
label={i18n.t('urlInput')} label={i18n.t('urlInput')}

View File

@@ -4,31 +4,24 @@ 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'
import DownloadsListView from './DownloadsListView' import DownloadsTableView from './DownloadsTableView'
const Downloads: React.FC = () => { const Downloads: React.FC = () => {
const listView = useRecoilValue(listViewState) const tableView = useRecoilValue(listViewState)
const loadingDownloads = useRecoilValue(loadingDownloadsState) const loadingDownloads = useRecoilValue(loadingDownloadsState)
const [isLoading, setIsLoading] = useRecoilState(loadingAtom) const [isLoading, setIsLoading] = useRecoilState(loadingAtom)
useEffect(() => { useEffect(() => {
if (loadingDownloads) { if (loadingDownloads) {
setIsLoading(true) return setIsLoading(true)
return
} }
setIsLoading(false) setIsLoading(false)
}, [loadingDownloads, isLoading]) }, [loadingDownloads, isLoading])
if (listView) { if (tableView) return <DownloadsTableView />
return (
<DownloadsListView />
)
}
return ( return <DownloadsCardView />
<DownloadsCardView />
)
} }
export default Downloads export default Downloads

View File

@@ -16,10 +16,10 @@ const DownloadsCardView: React.FC = () => {
const abort = (id: string) => client.kill(id) const abort = (id: string) => client.kill(id)
return ( return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}> <Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
{ {
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} xl={4} key={download.id}>
<DownloadCard <DownloadCard
download={download} download={download}
onStop={() => abort(download.id)} onStop={() => abort(download.id)}

View File

@@ -1,98 +0,0 @@
import {
Button,
Grid,
LinearProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from "@mui/material"
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useRPC } from '../hooks/useRPC'
import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
const DownloadsListView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState)
const { client } = useRPC()
const abort = (id: string) => client.kill(id)
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
<Grid item xs={12}>
<TableContainer
component={Paper}
sx={{ minHeight: '100%' }}
elevation={2}
hidden={downloads.length === 0}
>
<Table>
<TableHead>
<TableRow>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Title</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Progress</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Speed</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Size</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Actions</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{
downloads.map(download => (
<TableRow key={download.id}>
<TableCell>{ellipsis(download.info.title, 80)}</TableCell>
<TableCell>
<LinearProgress
value={
download.progress.percentage === '-1'
? 100
: Number(download.progress.percentage.replace('%', ''))
}
variant={
download.progress.process_status === 0
? 'indeterminate'
: 'determinate'
}
color={download.progress.percentage === '-1' ? 'success' : 'primary'}
/>
</TableCell>
<TableCell>{formatSpeedMiB(download.progress.speed)}</TableCell>
<TableCell>{roundMiB(download.info.filesize_approx ?? 0)}</TableCell>
<TableCell>
<Button
variant="contained"
size="small"
onClick={() => abort(download.id)}
>
{download.progress.percentage === '-1' ? 'Remove' : 'Stop'}
</Button>
</TableCell>
</TableRow>
))
}
</TableBody>
</Table>
</TableContainer>
</Grid>
</Grid>
)
}
export default DownloadsListView

View File

@@ -0,0 +1,159 @@
import DeleteIcon from '@mui/icons-material/Delete'
import DownloadIcon from '@mui/icons-material/Download'
import DownloadDoneIcon from '@mui/icons-material/DownloadDone'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
import SmartDisplayIcon from '@mui/icons-material/SmartDisplay'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import {
Box,
ButtonGroup,
IconButton,
LinearProgress,
LinearProgressProps,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from "@mui/material"
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC'
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress variant="determinate" {...props} />
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography variant="body2" color="text.secondary">{`${Math.round(
props.value,
)}%`}</Typography>
</Box>
</Box>
)
}
const DownloadsTableView: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const downloads = useRecoilValue(activeDownloadsState)
const { client } = useRPC()
const abort = (id: string) => client.kill(id)
const viewFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
}
const downloadFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
}
return (
<TableContainer
sx={{ minHeight: '80vh', mt: 4 }}
hidden={downloads.length === 0}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={8}>
<Typography fontWeight={500} fontSize={13}>Status</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={13}>Title</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight={500} fontSize={13}>Speed</Typography>
</TableCell>
<TableCell align="center" width={200}>
<Typography fontWeight={500} fontSize={13}>Progress</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight={500} fontSize={13}>Size</Typography>
</TableCell>
<TableCell align="right" width={180}>
<Typography fontWeight={500} fontSize={13}>Added on</Typography>
</TableCell>
<TableCell align="right" width={8}>
<Typography fontWeight={500} fontSize={13}>Actions</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{
downloads.map(download => (
<TableRow key={download.id}>
<TableCell>
{download.progress.percentage === '-1'
? <DownloadDoneIcon color="primary" />
: <DownloadIcon color="primary" />
}
</TableCell>
<TableCell>{download.info.title}</TableCell>
<TableCell align="right">{formatSpeedMiB(download.progress.speed)}</TableCell>
<TableCell align="right">
<LinearProgressWithLabel
sx={{ height: '16px' }}
value={
download.progress.percentage === '-1'
? 100
: Number(download.progress.percentage.replace('%', ''))
}
variant={
download.progress.process_status === 0
? 'indeterminate'
: 'determinate'
}
color={download.progress.percentage === '-1' ? 'primary' : 'primary'}
/>
</TableCell>
<TableCell align="right">{formatSize(download.info.filesize_approx ?? 0)}</TableCell>
<TableCell align="right">
{new Date(download.info.created_at).toLocaleString()}
</TableCell>
<TableCell align="right">
<ButtonGroup>
<IconButton
size="small"
onClick={() => abort(download.id)}
>
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}
</IconButton>
{download.progress.percentage === '-1' &&
<>
<IconButton
size="small"
onClick={() => viewFile(download.output.savedFilePath)}
>
<SmartDisplayIcon />
</IconButton>
<IconButton
size="small"
onClick={() => downloadFile(download.output.savedFilePath)}
>
<FileDownloadIcon />
</IconButton>
</>
}
</ButtonGroup>
</TableCell>
</TableRow>
))
}
</TableBody>
</Table>
</TableContainer>
)
}
export default DownloadsTableView

View File

@@ -39,7 +39,7 @@ const ErrorBoundary: React.FC = () => {
</Button> </Button>
</Link> </Link>
<Typography sx={{ mt: 2 }} color={'gray'} fontWeight={500}> <Typography sx={{ mt: 2 }} color={'gray'} fontWeight={500}>
Or login if authentication is enabled Or login if authentification is enabled
</Typography> </Typography>
<Link to={'/login'} > <Link to={'/login'} >
<Button variant='contained' sx={{ mt: 2 }}> <Button variant='contained' sx={{ mt: 2 }}>

View File

@@ -0,0 +1,69 @@
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import { AppBar, Chip, Divider, Toolbar } from '@mui/material'
import { Suspense } from 'react'
import { useRecoilValue } from 'recoil'
import { settingsState } from '../atoms/settings'
import { connectedState } from '../atoms/status'
import { useI18n } from '../hooks/useI18n'
import FreeSpaceIndicator from './FreeSpaceIndicator'
import VersionIndicator from './VersionIndicator'
import DownloadIcon from '@mui/icons-material/Download'
import { totalDownloadSpeedState } from '../atoms/ui'
import { formatSpeedMiB } from '../utils'
const Footer: React.FC = () => {
const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState)
const totalDownloadSpeed = useRecoilValue(totalDownloadSpeedState)
const mode = settings.theme
const { i18n } = useI18n()
return (
<AppBar position="fixed" color="default" sx={{
top: 'auto',
bottom: 0,
height: 48,
zIndex: 1200,
borderTop: mode === 'light'
? '1px solid rgba(0, 0, 0, 0.12)'
: '1px solid rgba(255, 255, 255, 0.12)',
}}>
<Toolbar sx={{
paddingBottom: 2,
fontSize: 14,
display: 'flex', gap: 1, justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<Chip label="RPC v3.0.8" variant="outlined" size="small" />
<VersionIndicator />
</div>
<div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}>
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
marginRight: 'px',
gap: 3,
}}>
<DownloadIcon />
<span>
{formatSpeedMiB(totalDownloadSpeed)}
</span>
<Divider orientation="vertical" flexItem />
<SettingsEthernet />
<span>
{isConnected ? settings.serverAddr : i18n.t('notConnectedText')}
</span>
</div>
<Divider orientation="vertical" flexItem />
<Suspense fallback={i18n.t('loadingLabel')}>
<FreeSpaceIndicator />
</Suspense>
</div>
</Toolbar>
</AppBar>
)
}
export default Footer

View File

@@ -1,7 +1,7 @@
import StorageIcon from '@mui/icons-material/Storage' import StorageIcon from '@mui/icons-material/Storage'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { freeSpaceBytesState } from '../atoms/status' import { freeSpaceBytesState } from '../atoms/status'
import { formatGiB } from '../utils' import { formatSize } from '../utils'
const FreeSpaceIndicator = () => { const FreeSpaceIndicator = () => {
const freeSpace = useRecoilValue(freeSpaceBytesState) const freeSpace = useRecoilValue(freeSpaceBytesState)
@@ -15,7 +15,7 @@ const FreeSpaceIndicator = () => {
}}> }}>
<StorageIcon /> <StorageIcon />
<span> <span>
{formatGiB(freeSpace)} {formatSize(freeSpace)}
</span> </span>
</div> </div>
) )

View File

@@ -27,6 +27,7 @@ const HomeActions: React.FC = () => {
setOpenDownload(false) setOpenDownload(false)
setIsLoading(true) setIsLoading(true)
}} }}
// TODO: handle optimistic UI update
onDownloadStart={(url) => { onDownloadStart={(url) => {
pushMessage(`Requested ${url}`, 'info') pushMessage(`Requested ${url}`, 'info')
setOpenDownload(false) setOpenDownload(false)

View File

@@ -2,13 +2,15 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'
import BuildCircleIcon from '@mui/icons-material/BuildCircle' 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 ViewAgendaIcon from '@mui/icons-material/ViewAgenda'
import FolderZipIcon from '@mui/icons-material/FolderZip'
import { import {
SpeedDial, SpeedDial,
SpeedDialAction, SpeedDialAction,
SpeedDialIcon SpeedDialIcon
} from '@mui/material' } from '@mui/material'
import { useRecoilState } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { listViewState } from '../atoms/settings' import { listViewState, serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
@@ -18,7 +20,8 @@ type Props = {
} }
const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => { const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const [, setListView] = useRecoilState(listViewState) const serverAddr = useRecoilValue(serverURL)
const [listView, setListView] = useRecoilState(listViewState)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
@@ -28,14 +31,19 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
return ( return (
<SpeedDial <SpeedDial
ariaLabel="Home speed dial" ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 32, right: 32 }} sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />} icon={<SpeedDialIcon />}
> >
<SpeedDialAction <SpeedDialAction
icon={<FormatListBulleted />} icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
tooltipTitle={`Table view`} tooltipTitle={listView ? 'Card view' : 'Table view'}
onClick={() => setListView(state => !state)} onClick={() => setListView(state => !state)}
/> />
<SpeedDialAction
icon={<FolderZipIcon />}
tooltipTitle={i18n.t('bulkDownload')}
onClick={() => window.open(`${serverAddr}/archive/bulk`)}
/>
<SpeedDialAction <SpeedDialAction
icon={<DeleteForeverIcon />} icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')} tooltipTitle={i18n.t('abortAllButton')}

View File

@@ -1,4 +1,3 @@
import { Box, CircularProgress, Container, Paper, Typography } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
@@ -7,14 +6,15 @@ import { useI18n } from '../hooks/useI18n'
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const LogTerminal: React.FC = () => { const LogTerminal: React.FC = () => {
const [logBuffer, setLogBuffer] = useState<string[]>([])
const [isConnecting, setIsConnecting] = useState(true)
const boxRef = useRef<HTMLDivElement>(null)
const serverAddr = useRecoilValue(serverURL) const serverAddr = useRecoilValue(serverURL)
const { i18n } = useI18n() const { i18n } = useI18n()
const [logBuffer, setLogBuffer] = useState<string[]>([])
const boxRef = useRef<HTMLDivElement>(null)
const eventSource = useMemo( const eventSource = useMemo(
() => new EventSource(`${serverAddr}/log/sse?token=${token}`), () => new EventSource(`${serverAddr}/log/sse?token=${token}`),
[serverAddr] [serverAddr]
@@ -23,7 +23,7 @@ const LogTerminal: React.FC = () => {
useEffect(() => { useEffect(() => {
eventSource.addEventListener('log', event => { eventSource.addEventListener('log', event => {
const msg: string[] = JSON.parse(event.data) const msg: string[] = JSON.parse(event.data)
setLogBuffer(buff => [...buff, ...msg].slice(-100)) setLogBuffer(buff => [...buff, ...msg].slice(-500))
boxRef.current?.scrollTo(0, boxRef.current.scrollHeight) boxRef.current?.scrollTo(0, boxRef.current.scrollHeight)
}) })
@@ -32,59 +32,51 @@ const LogTerminal: React.FC = () => {
return () => eventSource.close() return () => eventSource.close()
}, [eventSource]) }, [eventSource])
useEffect(() => {
eventSource.onopen = () => setIsConnecting(false)
}, [eventSource])
const logEntryStyle = (data: string) => { const logEntryStyle = (data: string) => {
const sx = {}
if (data.includes("level=ERROR")) { if (data.includes("level=ERROR")) {
return { color: 'red' } return { ...sx, color: 'red' }
} }
if (data.includes("level=WARN")) { if (data.includes("level=WARN")) {
return { color: 'orange' } return { ...sx, color: 'orange' }
} }
return {}
return sx
} }
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Paper <div
sx={{ ref={boxRef}
p: 2.5, style={{
display: 'flex', fontFamily: 'Roboto Mono',
flexDirection: 'column', height: '70.5vh',
}} overflowY: 'auto',
> overflowX: 'auto',
<Typography py={1} variant="h5" color="primary"> fontSize: '13.5px',
{i18n.t('logsTitle')} fontWeight: '600',
</Typography> backgroundColor: 'black',
{(logBuffer.length === 0) && <Box sx={{ color: 'white',
display: 'flex', padding: '0.5rem',
flexDirection: 'column', borderRadius: '0.25rem'
justifyItems: 'center', }}
alignItems: 'center', >
gap: 1 {isConnecting ? <div>{'Connecting...'}</div> : <div>{'Connected!'}</div>}
}}>
<CircularProgress color="primary" size={32} /> {logBuffer.length === 0 && <div>{i18n.t('awaitingLogs')}</div>}
<Typography py={1} variant="subtitle2" >
{i18n.t('awaitingLogs')} {logBuffer.map((log, idx) => (
</Typography> <div key={idx} style={logEntryStyle(log)}>
</Box> {log}
} </div>
<Box ))}
ref={boxRef} </div>
sx={{
fontFamily: 'Roboto Mono',
height: '75.5vh',
overflowY: 'auto',
overflowX: 'auto',
fontSize: '15px'
}}
>
{logBuffer.map((log, idx) => (
<Box key={idx} sx={logEntryStyle(log)}>
{log}
</Box>
))}
</Box>
</Paper>
</Container >
) )
} }

View File

@@ -52,7 +52,7 @@ const SocketSubscriber: React.FC<Props> = () => {
.filter(f => !!f.info.url).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,
)) )),
) )
) )
} }

View File

@@ -0,0 +1,38 @@
import { Chip, CircularProgress } from '@mui/material'
import { useEffect, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
const VersionIndicator: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const [version, setVersion] = useState('')
const { pushMessage } = useToast()
const fetchVersion = async () => {
const res = await fetch(`${serverAddr}/api/v1/version`, {
headers: {
'X-Authentication': localStorage.getItem('token') ?? ''
}
})
if (!res.ok) {
return pushMessage(await res.text(), 'error')
}
setVersion(await res.json())
}
useEffect(() => {
fetchVersion()
}, [])
return (
version
? <Chip label={`yt-dlp v${version}`} variant="outlined" size="small" />
: <CircularProgress size={15} />
)
}
export default VersionIndicator

View File

@@ -6,7 +6,6 @@ import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css' import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css' import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css' import '@fontsource/roboto/700.css'
import '@fontsource/roboto/700.css'
import '@fontsource/roboto-mono' import '@fontsource/roboto-mono'

View File

@@ -45,6 +45,9 @@ export type RPCResult = Readonly<{
id: string id: string
progress: DownloadProgress progress: DownloadProgress
info: DownloadInfo info: DownloadInfo
output: {
savedFilePath: string
}
}> }>
export type RPCParams = { export type RPCParams = {

View File

@@ -20,17 +20,10 @@ export function validateDomain(url: string): boolean {
return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug) return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug)
} }
export function isValidURL(url: string): boolean { export const ellipsis = (str: string, lim: number) =>
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/ str.length > lim
return urlRegex.test(url) ? `${str.substring(0, lim)}...`
} : str
export function ellipsis(str: string, lim: number): string {
if (str) {
return str.length > lim ? `${str.substring(0, lim)}...` : str
}
return ''
}
export function toFormatArgs(codes: string[]): string { export function toFormatArgs(codes: string[]): string {
if (codes.length > 1) { if (codes.length > 1) {
@@ -42,14 +35,21 @@ export function toFormatArgs(codes: string[]): string {
return '' return ''
} }
export const formatGiB = (bytes: number) => export function formatSize(bytes: number): string {
`${(bytes / 1_000_000_000).toFixed(0)}GiB` const threshold = 1024
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
export const roundMiB = (bytes: number) => let i = 0
`${(bytes / 1_000_000).toFixed(2)} MiB` while (bytes >= threshold) {
bytes /= threshold
i = i + 1
}
return `${bytes.toFixed(i == 0 ? 0 : 2)} ${units.at(i)}`
}
export const formatSpeedMiB = (val: number) => export const formatSpeedMiB = (val: number) =>
`${roundMiB(val)}/s` `${(val / 1_048_576).toFixed(2)} MiB/s`
export const datetimeCompareFunc = (a: string, b: string) => export const datetimeCompareFunc = (a: string, b: string) =>
new Date(a).getTime() - new Date(b).getTime() new Date(a).getTime() - new Date(b).getTime()

View File

@@ -41,7 +41,7 @@ import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { DirectoryEntry } from '../types' import { DirectoryEntry } from '../types'
import { base64URLEncode, roundMiB } from '../utils' import { base64URLEncode, formatSize } from '../utils'
export default function Downloaded() { export default function Downloaded() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }) const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
@@ -182,7 +182,7 @@ export default function Downloaded() {
return ( return (
<Container <Container
maxWidth="lg" maxWidth="xl"
sx={{ mt: 4, mb: 4, height: '100%' }} sx={{ mt: 4, mb: 4, height: '100%' }}
onClick={() => setShowMenu(false)} onClick={() => setShowMenu(false)}
> >
@@ -237,7 +237,7 @@ export default function Downloaded() {
variant="caption" variant="caption"
component="span" component="span"
> >
{roundMiB(file.size)} {formatSize(file.size)}
</Typography> </Typography>
} }
{!file.isDirectory && <> {!file.isDirectory && <>
@@ -274,8 +274,8 @@ export default function Downloaded() {
</List> </List>
</Paper> </Paper>
<SpeedDial <SpeedDial
ariaLabel="SpeedDial basic example" ariaLabel='archive actions'
sx={{ position: 'absolute', bottom: 32, right: 32 }} sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />} icon={<SpeedDialIcon />}
> >
<SpeedDialAction <SpeedDialAction

View File

@@ -8,7 +8,7 @@ import Splash from '../components/Splash'
export default function Home() { export default function Home() {
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}>
<LoadingBackdrop /> <LoadingBackdrop />
<Splash /> <Splash />
<Downloads /> <Downloads />

View File

@@ -39,6 +39,7 @@ import {
latestCliArgumentsState, latestCliArgumentsState,
pathOverridingState, pathOverridingState,
servedFromReverseProxyState, servedFromReverseProxyState,
servedFromReverseProxySubDirState,
serverAddressState, serverAddressState,
serverPortState, serverPortState,
themeState themeState
@@ -53,6 +54,7 @@ import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS // NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() { export default function Settings() {
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState) const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
const [baseURL, setBaseURL] = useRecoilState(servedFromReverseProxySubDirState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState) const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState) const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState) const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
@@ -73,9 +75,20 @@ export default function Settings() {
const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), []) const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), [])
const baseURL$ = useMemo(() => new Subject<string>(), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), []) const serverPort$ = useMemo(() => new Subject<string>(), [])
useEffect(() => {
const sub = baseURL$
.pipe(debounceTime(500))
.subscribe(baseURL => {
setBaseURL(baseURL)
pushMessage(i18n.t('restartAppMessage'), 'info')
})
return () => sub.unsubscribe()
}, [])
useEffect(() => { useEffect(() => {
const sub = serverAddr$ const sub = serverAddr$
.pipe( .pipe(
@@ -130,22 +143,22 @@ export default function Settings() {
* Updates yt-dlp binary via RPC * Updates yt-dlp binary via RPC
*/ */
const updateBinary = () => { const updateBinary = () => {
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'))) client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
} }
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid item xs={12} md={12} lg={12}> <Grid item xs={12} md={12} lg={12}>
<Paper <Paper
sx={{ sx={{
p: 2, p: 2.5,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 240, minHeight: 240,
}} }}
> >
<Typography pb={3} variant="h5" color="primary"> <Typography pb={2} variant="h6" color="primary">
{i18n.t('settingsAnchor')} {i18n.t('settingsAnchor')}
</Typography> </Typography>
<FormGroup> <FormGroup>
@@ -183,6 +196,9 @@ export default function Settings() {
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h6" color="primary" sx={{ mb: 0.5 }}>
Reverse Proxy
</Typography>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
@@ -191,10 +207,29 @@ export default function Settings() {
/> />
} }
label={i18n.t('servedFromReverseProxyCheckbox')} label={i18n.t('servedFromReverseProxyCheckbox')}
sx={{ mb: 1 }}
/>
<TextField
fullWidth
label={i18n.t('urlBase')}
defaultValue={baseURL}
onChange={(e) => {
let value = e.currentTarget.value
if (value.startsWith('/')) {
value = value.substring(1)
}
if (value.endsWith('/')) {
value = value.substring(0, value.length - 1)
}
baseURL$.next(value)
}}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> />
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 0.5, mb: 2 }}>
Appaerance
</Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<FormControl fullWidth> <FormControl fullWidth>
@@ -204,7 +239,7 @@ export default function Settings() {
label={i18n.t('languageSelect')} label={i18n.t('languageSelect')}
onChange={handleLanguageChange} onChange={handleLanguageChange}
> >
{languages.map(l => ( {languages.toSorted((a, b) => a.localeCompare(b)).map(l => (
<MenuItem value={l} key={l}> <MenuItem value={l} key={l}>
{capitalize(l)} {capitalize(l)}
</MenuItem> </MenuItem>
@@ -227,6 +262,9 @@ export default function Settings() {
</FormControl> </FormControl>
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
General download settings
</Typography>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -235,7 +273,7 @@ export default function Settings() {
/> />
} }
label={i18n.t('noMTimeCheckbox')} label={i18n.t('noMTimeCheckbox')}
sx={{ mt: 3 }}
/> />
<FormControlLabel <FormControlLabel
control={ control={
@@ -299,7 +337,7 @@ export default function Settings() {
/> />
</Stack> </Stack>
</Grid> </Grid>
<Grid sx={{ mr: 1, mt: 3 }}> <Grid sx={{ mr: 1, mt: 2 }}>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}> <Typography variant="h6" color="primary" sx={{ mb: 2 }}>
Cookies Cookies
</Typography> </Typography>

View File

@@ -1,8 +1,25 @@
import { Container, Paper, Typography } from '@mui/material'
import LogTerminal from '../components/LogTerminal' import LogTerminal from '../components/LogTerminal'
import { useI18n } from '../hooks/useI18n'
const Terminal: React.FC = () => { const Terminal: React.FC = () => {
const { i18n } = useI18n()
return ( return (
<LogTerminal /> <Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Paper
sx={{
p: 2.5,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography pb={2} variant="h5" color="primary">
{i18n.t('logsTitle')}
</Typography>
<LogTerminal />
</Paper>
</Container >
) )
} }

42
go.mod
View File

@@ -1,43 +1,39 @@
module github.com/marcopeocchi/yt-dlp-web-ui module github.com/marcopeocchi/yt-dlp-web-ui
go 1.20 go 1.22
require ( require (
github.com/go-chi/chi/v5 v5.0.11 github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
github.com/go-chi/chi/v5 v5.0.12
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.5.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
github.com/reactivex/rxgo/v2 v2.5.0 github.com/reactivex/rxgo/v2 v2.5.0
golang.org/x/sys v0.15.0 golang.org/x/sync v0.6.0
golang.org/x/sys v0.18.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.28.0 modernc.org/sqlite v1.29.5
) )
require ( require (
github.com/cenkalti/backoff/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/davecgh/go-spew v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.1.0 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.4.0 // indirect github.com/stretchr/testify v1.9.0 // indirect
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 // indirect github.com/teivah/onecontext v1.3.0 // indirect
golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.19.0 // indirect modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
golang.org/x/tools v0.16.1 // indirect modernc.org/libc v1.47.0 // indirect
gopkg.in/yaml.v2 v2.2.2 // 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/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
) )

95
go.sum
View File

@@ -1,100 +1,111 @@
github.com/cenkalti/backoff/v4 v4.0.0 h1:6VeaLF9aI+MAUQ95106HwWzYZgJJpZ4stumjj6RFYAU= github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/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/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4= github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4=
github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU= github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 h1:BLNsFR8l/hj/oGjnJXkd4Vi3s4kQD3/3x8HSAE4bzN0= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ= github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ=
github.com/teivah/onecontext v1.3.0 h1:tbikMhAlo6VhAuEGCvhc8HlTnpX4xTNPTOseWuhO1J0=
github.com/teivah/onecontext v1.3.0/go.mod h1:hoW1nmdPVK/0jrvGtcx8sCKYs2PiS4z0zzfdeuEVyb0=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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= modernc.org/cc/v4 v4.19.5 h1:QlsZyQ1zf78DGeqnQ9ILi9hXyMdoC5e1qoGNUyBjHQw=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v4 v4.19.5/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= modernc.org/ccgo/v4 v4.13.0 h1:99E8QHRoPrXN8VpS0zgAgJ5nSjpXrPKpsJIMvGL/2Oc=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= modernc.org/ccgo/v4 v4.13.0/go.mod h1:Td6RI9W9G2ZpKHaJ7UeGEiB2aIpoDqLBnm4wtkbJTbQ=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.38.0 h1:o4Lpk0zNDSdsjfEXnF1FGXWQ9PDi1NOdWcLP5n13FGo= modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/libc v1.38.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.47.0 h1:BXrzId9fOOkBtS+uFQ5aZyVGmt7WcSEPrXF5Kwsho90=
modernc.org/libc v1.47.0/go.mod h1:gzCncw0a74aCiVqHeWAYHHaW//fkSHHS/3S/gfhLlCI=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 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 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= 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 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE=
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=

28
main.go
View File

@@ -30,16 +30,21 @@ var (
userFromEnv = os.Getenv("USERNAME") userFromEnv = os.Getenv("USERNAME")
passFromEnv = os.Getenv("PASSWORD") passFromEnv = os.Getenv("PASSWORD")
logFile string
enableFileLogging bool
//go:embed frontend/dist/index.html //go:embed frontend/dist/index.html
//go:embed frontend/dist/assets/* //go:embed frontend/dist/assets/*
frontend embed.FS frontend embed.FS
//go:embed openapi/*
swagger embed.FS
) )
func init() { func init() {
flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at") 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", 2, "Queue size (concurrent downloads)")
flag.StringVar(&configFile, "conf", "./config.yml", "Config file path") flag.StringVar(&configFile, "conf", "./config.yml", "Config file path")
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
@@ -47,6 +52,9 @@ func init() {
flag.StringVar(&sessionFilePath, "session", ".", "session file path") flag.StringVar(&sessionFilePath, "session", ".", "session file path")
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path") flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file")
flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location")
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")
flag.StringVar(&password, "pass", passFromEnv, "Password required for auth") flag.StringVar(&password, "pass", passFromEnv, "Password required for auth")
@@ -56,7 +64,6 @@ func init() {
func main() { func main() {
frontend, err := fs.Sub(frontend, "frontend/dist") frontend, err := fs.Sub(frontend, "frontend/dist")
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
@@ -74,10 +81,23 @@ func main() {
c.Username = username c.Username = username
c.Password = password c.Password = password
// limit concurrent downloads for systems with 2 or less logical cores
if runtime.NumCPU() <= 2 {
c.QueueSize = 1
}
// 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.LoadFile(configFile); err != nil { if err := c.LoadFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, err) log.Println(cli.BgRed, "config", cli.Reset, err)
} }
server.RunBlocking(c.Host, c.Port, frontend, localDatabasePath) server.RunBlocking(&server.RunConfig{
Host: c.Host,
Port: c.Port,
DBPath: localDatabasePath,
FileLogging: enableFileLogging,
LogFile: logFile,
App: frontend,
Swagger: swagger,
})
} }

25
openapi/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.17.2/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.17.2/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: './openapi.json',
dom_id: '#swagger-ui',
});
};
</script>
</body>
</html>

273
openapi/openapi.json Normal file
View File

@@ -0,0 +1,273 @@
{
"openapi": "3.1.0",
"info": {
"title": "Swagger yt-dlp-webui - OpenAPI 3.1",
"description": "yt-dlp-webui api based on the OpenAPI 3.1 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). \n\nSome useful links:\n- [yt-dlp-webui repository](https://github.com/marcopeocchi/yt-dlp-web-ui)",
"termsOfService": "http://swagger.io/terms/",
"contact": {
"email": "apiteam@swagger.io"
},
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "1.0.11"
},
"externalDocs": {
"description": "Find out more about Swagger",
"url": "http://swagger.io"
},
"servers": [
{
"url": "/api/v1"
}
],
"tags": [
{
"name": "download",
"description": "Everything about your Pets",
"externalDocs": {
"description": "Find out more",
"url": "https://github.com/marcopeocchi/yt-dlp-web-ui"
}
}
],
"paths": {
"/exec": {
"post": {
"tags": [
"download"
],
"summary": "Add a new download in the pending state ready to be processed",
"description": "Add a new download in the pending state ready to be processed",
"operationId": "addDownload",
"requestBody": {
"description": "Create a new download",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DownloadRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful operation",
"content": {
"application/json": {
"schema": {
"type": "string",
"description": "Process uuid"
}
}
}
},
"400": {
"description": "Invalid input"
},
"422": {
"description": "Validation exception"
}
},
"security": [
{
"api_key": [
"write:download",
"read:download"
]
}
]
}
},
"/running": {
"get": {
"tags": [
"download"
],
"summary": "Returns all running and pending process",
"description": "Returns all running and pending process",
"operationId": "running",
"responses": {
"200": {
"description": "Successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProcessResponse"
}
}
}
},
"400": {
"description": "Invalid input"
},
"422": {
"description": "Validation exception"
}
},
"security": [
{
"api_key": [
"write:download",
"read:download"
]
}
]
}
}
},
"components": {
"schemas": {
"DownloadRequest": {
"type": "object",
"properties": {
"url": {
"type": "string",
"examples": [
"https://..."
]
},
"params": {
"type": "array",
"format": "string",
"examples": [
"-N",
"4",
"-R",
"infinite"
]
}
}
},
"DownloadResponse": {
"type": "object",
"properties": {
"url": {
"type": "string",
"examples": [
"https://..."
]
},
"params": {
"type": "array",
"format": "string",
"examples": [
"-N",
"4",
"-R",
"infinite"
]
}
}
},
"DownloadProgress": {
"type": "object",
"properties": {
"process_status": {
"type": "integer",
"examples": [
0,
1,
2,
3
]
},
"percentage": {
"type": "string",
"examples": [
"50%"
]
},
"speed": {
"type": "integer",
"examples": [
7289347
]
},
"eta": {
"type": "integer",
"examples": [
3600
]
}
}
},
"DownloadInfo": {
"type": "object",
"properties": {
"url": {
"type": "string"
},
"title": {
"type": "string"
},
"thumbnail": {
"type": "string"
},
"resolution": {
"type": "string"
},
"size": {
"type": "integer"
},
"vcodec": {
"type": "string"
},
"acodec": {
"type": "string"
},
"extension": {
"type": "string"
},
"original_url": {
"type": "string"
},
"created_at": {
"type": "object"
}
}
},
"DownloadOutput": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"filename": {
"type": "string"
},
"saveFilePath": {
"type": "string"
}
}
},
"ProcessResponse": {
"type": "object",
"properties": {
"progress": {
"$ref": "#/components/schemas/DownloadProgress"
},
"info": {
"$ref": "#/components/schemas/DownloadInfo"
},
"output": {
"$ref": "#/components/schemas/DownloadOutput"
},
"params": {
"type": "array",
"format": "string"
}
}
}
},
"securitySchemes": {
"api_key": {
"type": "apiKey",
"name": "api_key",
"in": "header"
}
}
}
}

65
proto/yt-dlp.proto Normal file
View File

@@ -0,0 +1,65 @@
syntax = "proto3";
message Empty {}
message BaseRequest {
string id = 1;
string url = 2;
}
message DownloadRequest {
string id = 1;
string url = 2;
string path = 3;
string rename = 4;
repeated string params = 5;
}
message ExecResponse {
string id = 1;
}
message DownloadProgress {
int32 status = 1;
string percentage = 2;
float speed = 3;
float eta = 4;
}
message DownloadInfo {
string url = 1;
string title = 2;
string thumbnail = 3;
string resolution = 4;
int32 size = 5;
string vcodec = 6;
string acodec = 7;
string extension = 8;
string originalURL = 9;
string createdAt = 10;
}
message DownloadOutput {
string path = 1;
string filename = 2;
string savedFilePath = 3;
}
message ProcessResponse {
string id = 1;
DownloadProgress progress = 2;
DownloadInfo info = 3;
DownloadOutput output = 4;
repeated string params = 5;
}
service Ytdlp {
rpc Exec (DownloadRequest) returns (ExecResponse);
rpc ExecPlaylist (DownloadRequest) returns (ExecResponse);
rpc Progress (BaseRequest) returns (DownloadProgress);
rpc Running (Empty) returns (stream ProcessResponse);
rpc Kill (BaseRequest) returns (ExecResponse);
rpc KillAll (Empty) returns (stream ExecResponse);
}

View File

@@ -35,6 +35,7 @@ func Instance() *Config {
return instance return instance
} }
// Initialises the Config struct given its config file
func (c *Config) LoadFile(filename string) error { func (c *Config) LoadFile(filename string) error {
fd, err := os.Open(filename) fd, err := os.Open(filename)
if err != nil { if err != nil {

View File

@@ -1,10 +1,11 @@
package dbutils package dbutil
import ( import (
"context" "context"
"database/sql" "database/sql"
) )
// Run the table migration
func AutoMigrate(ctx context.Context, db *sql.DB) error { func AutoMigrate(ctx context.Context, db *sql.DB) error {
conn, err := db.Conn(ctx) conn, err := db.Conn(ctx)
if err != nil { if err != nil {

View File

@@ -1,22 +1,45 @@
package handlers package handlers
import ( import (
"archive/zip"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"io/fs"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
"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/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
/*
File based operation handlers (should be moved to rest/handlers.go) or in
a entirely self-contained package
*/
var (
videoRe = regexp.MustCompile(`(?i)/\.mov|\.mp4|\.webm|\.mvk|/gmi`)
)
func isVideo(d fs.DirEntry) bool {
return videoRe.MatchString(d.Name())
}
func isValidEntry(d fs.DirEntry) bool {
return !strings.HasPrefix(d.Name(), ".") &&
!strings.HasSuffix(d.Name(), ".part") &&
!strings.HasSuffix(d.Name(), ".ytdl")
}
type DirectoryEntry struct { type DirectoryEntry struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
@@ -35,7 +58,7 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
var files []DirectoryEntry var files []DirectoryEntry
for _, d := range dirs { for _, d := range dirs {
if !utils.IsValidEntry(d) { if !isValidEntry(d) {
continue continue
} }
@@ -50,7 +73,7 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
Path: path, Path: path,
Name: d.Name(), Name: d.Name(),
Size: info.Size(), Size: info.Size(),
IsVideo: utils.IsVideo(d), IsVideo: isVideo(d),
IsDirectory: d.IsDir(), IsDirectory: d.IsDir(),
ModTime: info.ModTime(), ModTime: info.ModTime(),
}) })
@@ -169,14 +192,8 @@ func DownloadFile(w http.ResponseWriter, r *http.Request) {
root := config.Instance().DownloadPath root := config.Instance().DownloadPath
if strings.Contains(filepath.Dir(filename), root) { if strings.Contains(filepath.Dir(filename), root) {
w.Header().Add( w.Header().Add("Content-Disposition", "inline; filename=\""+filepath.Base(filename)+"\"")
"Content-Disposition", w.Header().Set("Content-Type", "application/octet-stream")
"inline; filename="+filepath.Base(filename),
)
w.Header().Set(
"Content-Type",
"application/octet-stream",
)
fd, err := os.Open(filename) fd, err := os.Open(filename)
if err != nil { if err != nil {
@@ -189,3 +206,47 @@ func DownloadFile(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
} }
func BulkDownload(mdb *internal.MemoryDB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ps := slices.DeleteFunc(*mdb.All(), func(e internal.ProcessResponse) bool {
return e.Progress.Status != internal.StatusCompleted
})
if len(ps) == 0 {
return
}
zipWriter := zip.NewWriter(w)
w.Header().Add(
"Content-Disposition",
"inline; filename=download-archive-"+time.Now().Format(time.RFC3339)+".zip",
)
w.Header().Set("Content-Type", "application/zip")
for _, p := range ps {
wr, err := zipWriter.Create(filepath.Base(p.Output.SavedFilePath))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fd, err := os.Open(p.Output.SavedFilePath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := io.Copy(wr, fd); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := zipWriter.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}

View File

@@ -8,18 +8,19 @@ import (
"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"
) )
const TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui"
type LoginRequest struct { type LoginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
func Login(w http.ResponseWriter, r *http.Request) { func Login(w http.ResponseWriter, r *http.Request) {
req := new(LoginRequest) var req LoginRequest
err := json.NewDecoder(r.Body).Decode(req)
if 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)
return return
} }
@@ -55,7 +56,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
func Logout(w http.ResponseWriter, r *http.Request) { func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{ cookie := &http.Cookie{
Name: utils.TOKEN_COOKIE_NAME, Name: TOKEN_COOKIE_NAME,
HttpOnly: true, HttpOnly: true,
Secure: false, Secure: false,
Expires: time.Now(), Expires: time.Now(),

View File

@@ -0,0 +1,34 @@
package internal
import (
"container/heap"
)
type LoadBalancer struct {
pool Pool
done chan *Worker
}
func (b *LoadBalancer) Balance(work chan Process) {
for {
select {
case req := <-work:
b.dispatch(req)
case w := <-b.done:
b.completed(w)
}
}
}
func (b *LoadBalancer) dispatch(req Process) {
w := heap.Pop(&b.pool).(*Worker)
w.requests <- req
w.pending++
heap.Push(&b.pool, w)
}
func (b *LoadBalancer) completed(w *Worker) {
w.pending--
heap.Remove(&b.pool, w.index)
heap.Push(&b.pool, w)
}

View File

@@ -2,6 +2,21 @@ package internal
import "time" import "time"
// Used to unmarshall yt-dlp progress
type ProgressTemplate struct {
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
Size string `json:"size"`
Eta float32 `json:"eta"`
}
// Defines where and how the download needs to be saved
type DownloadOutput struct {
Path string
Filename string
SavedFilePath string `json:"savedFilePath"`
}
// Progress for the Running call // Progress for the Running call
type DownloadProgress struct { type DownloadProgress struct {
Status int `json:"process_status"` Status int `json:"process_status"`
@@ -79,6 +94,7 @@ type SetCookiesRequest struct {
Cookies string `json:"cookies"` Cookies string `json:"cookies"`
} }
// represents a user defined collection of yt-dlp arguments
type CustomTemplate struct { type CustomTemplate struct {
Id string `json:"id"` Id string `json:"id"`
Name string `json:"name"` Name string `json:"name"`

View File

@@ -3,7 +3,6 @@ package internal
import ( import (
"encoding/gob" "encoding/gob"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@@ -24,61 +23,40 @@ func (m *MemoryDB) Get(id string) (*Process, error) {
if !ok { if !ok {
return nil, errors.New("no process found for the given key") return nil, errors.New("no process found for the given key")
} }
return entry.(*Process), nil return entry.(*Process), nil
} }
// Store a pointer of a process and return its id // Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string { func (m *MemoryDB) Set(process *Process) string {
id := uuid.NewString() id := uuid.NewString()
m.table.Store(id, process) m.table.Store(id, process)
process.Id = id process.Id = id
return id return id
} }
// Update a process info/metadata, given the process id
//
// Deprecated: will be removed anytime soon.
func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error {
entry, ok := m.table.Load(id)
if ok {
entry.(*Process).Info = info
m.table.Store(id, entry)
return nil
}
return fmt.Errorf("can't update row with id %s", id)
}
// Update a process progress data, given the process id
// Used for updating completition percentage or ETA.
//
// Deprecated: will be removed anytime soon.
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
entry, ok := m.table.Load(id)
if ok {
entry.(*Process).Progress = progress
m.table.Store(id, entry)
return nil
}
return fmt.Errorf("can't update row with id %s", id)
}
// Removes a process progress, given the process id // Removes a process progress, given the process id
func (m *MemoryDB) Delete(id string) { func (m *MemoryDB) Delete(id string) {
m.table.Delete(id) m.table.Delete(id)
} }
func (m *MemoryDB) Keys() *[]string { func (m *MemoryDB) Keys() *[]string {
running := []string{} var running []string
m.table.Range(func(key, value any) bool { m.table.Range(func(key, value any) bool {
running = append(running, key.(string)) running = append(running, key.(string))
return true return true
}) })
return &running return &running
} }
// Returns a slice of all currently stored processes progess // Returns a slice of all currently stored processes progess
func (m *MemoryDB) All() *[]ProcessResponse { func (m *MemoryDB) All() *[]ProcessResponse {
running := []ProcessResponse{} running := []ProcessResponse{}
m.table.Range(func(key, value any) bool { m.table.Range(func(key, value any) bool {
running = append(running, ProcessResponse{ running = append(running, ProcessResponse{
Id: key.(string), Id: key.(string),
@@ -89,10 +67,11 @@ func (m *MemoryDB) All() *[]ProcessResponse {
}) })
return true return true
}) })
return &running return &running
} }
// WIP: Persist the database in a single file named "session.dat" // Persist the database in a single file named "session.dat"
func (m *MemoryDB) Persist() error { func (m *MemoryDB) Persist() error {
running := m.All() running := m.All()
@@ -103,29 +82,25 @@ func (m *MemoryDB) Persist() error {
return errors.Join(errors.New("failed to persist session"), err) return errors.Join(errors.New("failed to persist session"), err)
} }
session := Session{ session := Session{Processes: *running}
Processes: *running,
}
err = gob.NewEncoder(fd).Encode(session) if err := gob.NewEncoder(fd).Encode(session); err != nil {
if err != nil {
return errors.Join(errors.New("failed to persist session"), err) return errors.Join(errors.New("failed to persist session"), err)
} }
return nil return nil
} }
// WIP: Restore a persisted state // Restore a persisted state
func (m *MemoryDB) Restore(logger *slog.Logger) { func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) {
fd, err := os.Open("session.dat") fd, err := os.Open("session.dat")
if err != nil { if err != nil {
return return
} }
session := Session{} var session Session
err = gob.NewDecoder(fd).Decode(&session) if err := gob.NewDecoder(fd).Decode(&session); err != nil {
if err != nil {
return return
} }
@@ -142,8 +117,8 @@ func (m *MemoryDB) Restore(logger *slog.Logger) {
m.table.Store(proc.Id, restored) m.table.Store(proc.Id, restored)
if restored.Progress.Percentage != "-1" { if restored.Progress.Status != StatusCompleted {
go restored.Start() mq.Publish(restored)
} }
} }
} }

View File

@@ -1,63 +1,102 @@
package internal package internal
import ( import (
"context"
"errors"
"log/slog"
evbus "github.com/asaskevich/EventBus"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"golang.org/x/sync/semaphore"
) )
const queueName = "process:pending"
type MessageQueue struct { type MessageQueue struct {
producerCh chan *Process concurrency int
consumerCh chan struct{} eventBus evbus.Bus
logger *slog.Logger
} }
// Creates a new message queue. // Creates a new message queue.
// By default it will be created with a size equals to nthe number of logical // By default it will be created with a size equals to nthe number of logical
// CPU cores. // CPU cores -1.
// 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(l *slog.Logger) (*MessageQueue, error) {
size := config.Instance().QueueSize qs := config.Instance().QueueSize
if size <= 0 { if qs <= 0 {
panic("invalid queue size") return nil, errors.New("invalid queue size")
} }
return &MessageQueue{ return &MessageQueue{
producerCh: make(chan *Process, size), concurrency: qs,
consumerCh: make(chan struct{}, size), eventBus: evbus.New(),
} logger: l,
}, nil
} }
// Publish a message to the queue and set the task to a peding state. // Publish a message to the queue and set the task to a peding state.
func (m *MessageQueue) Publish(p *Process) { func (m *MessageQueue) Publish(p *Process) {
// needs to have an id set before
p.SetPending() p.SetPending()
go p.SetMetadata()
m.producerCh <- p m.eventBus.Publish(queueName, p)
} }
// Publish a message to the queue and set the task to a peding state. func (m *MessageQueue) SetupConsumers() {
// ENSURE P IS PART OF A PLAYLIST go m.downloadConsumer()
// Needs a further review go m.metadataSubscriber()
func (m *MessageQueue) PublishPlaylistEntry(p *Process) {
m.producerCh <- p
} }
// Setup the consumer listener which subscribes to the changes to the producer // Setup the consumer listener which subscribes to the changes to the producer
// channel and triggers the "download" action. // channel and triggers the "download" action.
func (m *MessageQueue) Subscriber() { func (m *MessageQueue) downloadConsumer() {
for msg := range m.producerCh { sem := semaphore.NewWeighted(int64(m.concurrency))
m.consumerCh <- struct{}{}
go func(p *Process) { m.eventBus.SubscribeAsync(queueName, func(p *Process) {
p.Start() //TODO: provide valid context
<-m.consumerCh sem.Acquire(context.TODO(), 1)
}(msg) defer sem.Release(1)
}
m.logger.Info("received process from event bus",
slog.String("bus", queueName),
slog.String("consumer", "downloadConsumer"),
slog.String("id", p.getShortId()),
)
p.Start()
m.logger.Info("started process",
slog.String("bus", queueName),
slog.String("id", p.getShortId()),
)
}, false)
} }
// Empties the message queue // Setup the metadata consumer listener which subscribes to the changes to the
func (m *MessageQueue) Empty() { // producer channel and adds metadata to each download.
for range m.producerCh { func (m *MessageQueue) metadataSubscriber() {
<-m.producerCh // How many concurrent metadata fetcher jobs are spawned
} // Since there's ongoing downloads, 1 job at time seems a good compromise
for range m.consumerCh { sem := semaphore.NewWeighted(1)
<-m.consumerCh
} m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.TODO(), 1)
defer sem.Release(1)
m.logger.Info("received process from event bus",
slog.String("bus", queueName),
slog.String("consumer", "metadataConsumer"),
slog.String("id", p.getShortId()),
)
if err := p.SetMetadata(); err != nil {
m.logger.Error("failed to retrieve metadata",
slog.String("id", p.getShortId()),
slog.String("err", err.Error()),
)
}
}, false)
} }

View File

@@ -29,17 +29,15 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
return err return err
} }
m := metadata{} var m metadata
err = cmd.Start() if err := cmd.Start(); err != nil {
if err != nil {
return err return err
} }
logger.Info("decoding metadata", slog.String("url", req.URL)) logger.Info("decoding playlist metadata", slog.String("url", req.URL))
err = json.NewDecoder(stdout).Decode(&m) if err := json.NewDecoder(stdout).Decode(&m); err != nil {
if err != nil {
return err return err
} }
@@ -57,9 +55,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
slog.Int("count", m.Count), slog.Int("count", m.Count),
) )
for i, meta := range m.Entries { for _, meta := range m.Entries {
delta := time.Second.Microseconds() * int64(i+1)
// detect playlist title from metadata since each playlist entry will be // detect playlist title from metadata since each playlist entry will be
// treated as an individual download // treated as an individual download
req.Rename = strings.Replace( req.Rename = strings.Replace(
@@ -72,19 +68,18 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
proc := &Process{ proc := &Process{
Url: meta.OriginalURL, Url: meta.OriginalURL,
Progress: DownloadProgress{}, Progress: DownloadProgress{},
Output: DownloadOutput{ Output: DownloadOutput{Filename: req.Rename},
Filename: req.Rename, Info: meta,
}, Params: req.Params,
Info: meta, Logger: logger,
Params: req.Params,
} }
proc.Info.URL = meta.OriginalURL proc.Info.URL = meta.OriginalURL
proc.Info.CreatedAt = time.Now().Add(time.Duration(delta))
time.Sleep(time.Millisecond)
db.Set(proc) db.Set(proc)
proc.SetPending() mq.Publish(proc)
mq.PublishPlaylistEntry(proc)
} }
err = cmd.Wait() err = cmd.Wait()
@@ -97,6 +92,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
Logger: logger, Logger: logger,
} }
db.Set(proc)
mq.Publish(proc) mq.Publish(proc)
logger.Info("sending new process to message queue", slog.String("url", proc.Url)) logger.Info("sending new process to message queue", slog.String("url", proc.Url))

16
server/internal/pool.go Normal file
View File

@@ -0,0 +1,16 @@
package internal
type Pool []*Worker
func (h Pool) Len() int { return len(h) }
func (h Pool) Less(i, j int) bool { return h[i].index < h[j].index }
func (h Pool) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) }
func (h *Pool) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}

View File

@@ -2,10 +2,14 @@ package internal
import ( import (
"bufio" "bufio"
"bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"regexp" "regexp"
"slices"
"sync" "sync"
"syscall" "syscall"
@@ -15,7 +19,6 @@ import (
"strings" "strings"
"time" "time"
"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" "github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
@@ -35,13 +38,6 @@ const (
StatusErrored StatusErrored
) )
type ProgressTemplate struct {
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
Size string `json:"size"`
Eta float32 `json:"eta"`
}
// Process descriptor // Process descriptor
type Process struct { type Process struct {
Id string Id string
@@ -54,11 +50,6 @@ type Process struct {
Logger *slog.Logger Logger *slog.Logger
} }
type DownloadOutput struct {
Path string
Filename string
}
// Starts spawns/forks a new yt-dlp process and parse its stdout. // Starts spawns/forks a new yt-dlp process and parse its stdout.
// The process is spawned to outputting a custom progress text that // The process is spawned to outputting a custom progress text that
// Resembles a JSON Object in order to Unmarshal it later. // Resembles a JSON Object in order to Unmarshal it later.
@@ -67,13 +58,13 @@ type DownloadOutput struct {
func (p *Process) Start() { func (p *Process) Start() {
// escape bash variable escaping and command piping, you'll never know // escape bash variable escaping and command piping, you'll never know
// what they might come with... // what they might come with...
p.Params = slices.Filter(p.Params, func(e string) bool { p.Params = slices.DeleteFunc(p.Params, func(e string) bool {
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e) match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
return !match return match
}) })
p.Params = slices.Filter(p.Params, func(e string) bool { p.Params = slices.DeleteFunc(p.Params, func(e string) bool {
return e != "" return e == ""
}) })
out := DownloadOutput{ out := DownloadOutput{
@@ -91,7 +82,10 @@ func (p *Process) Start() {
buildFilename(&p.Output) buildFilename(&p.Output)
params := []string{ //TODO: it spawn another one yt-dlp process, too slow.
go p.GetFileName(&out)
baseParams := []string{
strings.Split(p.Url, "?list")[0], //no playlist strings.Split(p.Url, "?list")[0], //no playlist
"--newline", "--newline",
"--no-colors", "--no-colors",
@@ -101,12 +95,12 @@ func (p *Process) Start() {
} }
// if user asked to manually override the output path... // if user asked to manually override the output path...
if !(slices.Includes(params, "-P") || slices.Includes(params, "--paths")) { if !(slices.Contains(p.Params, "-P") || slices.Contains(p.Params, "--paths")) {
params = append(params, "-o") p.Params = append(p.Params, "-o")
params = append(params, fmt.Sprintf("%s/%s", out.Path, out.Filename)) p.Params = append(p.Params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
} }
params = append(params, p.Params...) params := append(baseParams, p.Params...)
// ----------------- main block ----------------- // // ----------------- main block ----------------- //
cmd := exec.Command(config.Instance().DownloaderPath, params...) cmd := exec.Command(config.Instance().DownloaderPath, params...)
@@ -120,10 +114,8 @@ func (p *Process) Start() {
) )
panic(err) panic(err)
} }
scan := bufio.NewScanner(r)
err = cmd.Start() if err := cmd.Start(); err != nil {
if err != nil {
p.Logger.Error( p.Logger.Error(
"failed to start yt-dlp process", "failed to start yt-dlp process",
slog.String("err", err.Error()), slog.String("err", err.Error()),
@@ -133,6 +125,8 @@ func (p *Process) Start() {
p.proc = cmd.Process p.proc = cmd.Process
go p.SetMetadata()
// --------------- progress block --------------- // // --------------- progress block --------------- //
var ( var (
sourceChan = make(chan []byte) sourceChan = make(chan []byte)
@@ -142,10 +136,14 @@ func (p *Process) Start() {
// 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)
go func() { go func() {
scan := bufio.NewScanner(r)
defer func() { defer func() {
r.Close() r.Close()
p.Complete() p.Complete()
doneChan <- struct{}{} doneChan <- struct{}{}
close(sourceChan) close(sourceChan)
close(doneChan) close(doneChan)
}() }()
@@ -158,21 +156,24 @@ func (p *Process) Start() {
// Slows down the unmarshal operation to every 500ms // Slows down the unmarshal operation to every 500ms
go func() { go func() {
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) { rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
stdout := ProgressTemplate{} var progress ProgressTemplate
err := json.Unmarshal(event, &stdout)
if err == nil { if err := json.Unmarshal(event, &progress); err != nil {
p.Progress = DownloadProgress{ return
Status: StatusDownloading,
Percentage: stdout.Percentage,
Speed: stdout.Speed,
ETA: stdout.Eta,
}
p.Logger.Info("progress",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("percentege", stdout.Percentage),
)
} }
p.Progress = DownloadProgress{
Status: StatusDownloading,
Percentage: progress.Percentage,
Speed: progress.Speed,
ETA: progress.Eta,
}
p.Logger.Info("progress",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("percentage", progress.Percentage),
)
}) })
}() }()
@@ -218,11 +219,16 @@ func (p *Process) Kill() error {
} }
// Returns the available format for this URL // Returns the available format for this URL
// TODO: Move out from process.go
func (p *Process) GetFormatsSync() (DownloadFormats, error) { func (p *Process) GetFormatsSync() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().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 {
p.Logger.Error(
"failed to retrieve metadata",
slog.String("err", err.Error()),
)
return DownloadFormats{}, err return DownloadFormats{}, err
} }
@@ -236,10 +242,6 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
wg.Add(2) wg.Add(2)
if err != nil {
return DownloadFormats{}, err
}
log.Println( log.Println(
cli.BgRed, "Metadata", cli.Reset, cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, "Formats", cli.Reset, cli.BgBlue, "Formats", cli.Reset,
@@ -273,7 +275,31 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
return info, nil return info, nil
} }
func (p *Process) GetFileName(o *DownloadOutput) error {
cmd := exec.Command(
config.Instance().DownloaderPath,
"--print", "filename",
"-o", fmt.Sprintf("%s/%s", o.Path, o.Filename),
p.Url,
)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
out, err := cmd.Output()
if err != nil {
return err
}
p.Output.SavedFilePath = strings.Trim(string(out), "\n")
return nil
}
func (p *Process) SetPending() { func (p *Process) SetPending() {
// Since video's title isn't available yet, fill in with the URL.
p.Info = DownloadInfo{
URL: p.Url,
Title: p.Url,
CreatedAt: time.Now(),
}
p.Progress.Status = StatusPending p.Progress.Status = StatusPending
} }
@@ -283,7 +309,17 @@ func (p *Process) SetMetadata() error {
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
p.Logger.Error("failed retrieving info", p.Logger.Error("failed to connect to stdout",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", err.Error()),
)
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
p.Logger.Error("failed to connect to stderr",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("err", err.Error()), slog.String("err", err.Error()),
@@ -296,32 +332,36 @@ func (p *Process) SetMetadata() error {
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
err = cmd.Start() if err := cmd.Start(); err != nil {
if err != nil {
return err return err
} }
var bufferedStderr bytes.Buffer
go func() {
io.Copy(&bufferedStderr, stderr)
}()
p.Logger.Info("retrieving metadata", p.Logger.Info("retrieving metadata",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
) )
err = json.NewDecoder(stdout).Decode(&info) if err := json.NewDecoder(stdout).Decode(&info); err != nil {
if err != nil {
return err return err
} }
p.Info = info p.Info = info
p.Progress.Status = StatusPending p.Progress.Status = StatusPending
err = cmd.Wait() if err := cmd.Wait(); err != nil {
return errors.New(bufferedStderr.String())
}
return err return nil
} }
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) { func buildFilename(o *DownloadOutput) {
if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") { if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") {

View File

@@ -1,33 +1,37 @@
package internal package internal
type Node[T any] struct {
Value T
}
type Stack[T any] struct { type Stack[T any] struct {
Elements []*T Nodes []*Node[T]
count int count int
} }
func NewStack[T any]() *Stack[T] { func NewStack[T any]() *Stack[T] {
return &Stack[T]{ return &Stack[T]{
Elements: make([]*T, 10), Nodes: make([]*Node[T], 10),
} }
} }
func (s *Stack[T]) Push(val T) { func (s *Stack[T]) Push(val T) {
if s.count >= len(s.Elements) { if s.count >= len(s.Nodes) {
Elements := make([]*T, len(s.Elements)*2) Nodes := make([]*Node[T], len(s.Nodes)*2)
copy(Elements, s.Elements) copy(Nodes, s.Nodes)
s.Elements = Elements s.Nodes = Nodes
} }
s.Elements[s.count] = &val s.Nodes[s.count] = &Node[T]{Value: val}
s.count++ s.count++
} }
func (s *Stack[T]) Pop() *T { func (s *Stack[T]) Pop() *Node[T] {
if s.count == 0 { if s.count == 0 {
return nil return nil
} }
Element := s.Elements[s.count-1] node := s.Nodes[s.count-1]
s.count-- s.count--
return Element return node
} }
func (s *Stack[T]) IsEmpty() bool { func (s *Stack[T]) IsEmpty() bool {

15
server/internal/worker.go Normal file
View File

@@ -0,0 +1,15 @@
package internal
type Worker struct {
requests chan Process // downloads to do
pending int // downloads pending
index int // index in the heap
}
func (w *Worker) Work(done chan *Worker) {
for {
req := <-w.requests
req.Start()
done <- w
}
}

View File

@@ -0,0 +1,89 @@
package logging
import (
"compress/gzip"
"io"
"os"
"sync"
"time"
)
/*
File base logger with log-rotate capabilities.
The rotate process must be initiated from an external goroutine.
After rotation the previous logs file are compressed with gzip algorithm.
The rotated log follows this naming: [filename].UTC time.gz
*/
// implements io.Writer interface
type LogRotateWriter struct {
mu sync.Mutex
fd *os.File
filename string
}
func NewRotableLogger(filename string) (*LogRotateWriter, error) {
fd, err := os.Create(filename)
if err != nil {
return nil, err
}
w := &LogRotateWriter{filename: filename, fd: fd}
return w, nil
}
func (w *LogRotateWriter) Write(b []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.fd.Write(b)
}
func (w *LogRotateWriter) Rotate() error {
var err error
w.mu.Lock()
gzFile, err := os.Create(w.filename + "." + time.Now().Format(time.RFC3339) + ".gz")
if err != nil {
return err
}
data, err := io.ReadAll(w.fd)
if err != nil {
return err
}
defer func() {
w.mu.Unlock()
w.gzipLog(gzFile, &data)
}()
_, err = os.Stat(w.filename)
if err != nil {
return err
}
if w.fd != nil {
err = w.fd.Close()
w.fd = nil
if err != nil {
return err
}
}
err = os.Remove(w.filename)
if err != nil {
return err
}
w.fd, err = os.Create(w.filename)
return err
}
func (w *LogRotateWriter) gzipLog(wr io.Writer, data *[]byte) error {
if _, err := gzip.NewWriter(wr).Write(*data); err != nil {
return err
}
return nil
}

View File

@@ -6,10 +6,21 @@ import (
"github.com/reactivex/rxgo/v2" "github.com/reactivex/rxgo/v2"
) )
/*
Logger implementation using the observable pattern.
Implements io.Writer interface.
The observable is an event source which drops everythigng unless there's
a subscriber connected.
The observer implementatios are a http ServerSentEvents handler and a
websocket one in handler.go
*/
var ( var (
logsChan = make(chan rxgo.Item, 100) logsChan = make(chan rxgo.Item, 100)
logsObservable = rxgo. logsObservable = rxgo.
FromChannel(logsChan, rxgo.WithBackPressureStrategy(rxgo.Drop)). FromEventSource(logsChan, rxgo.WithBackPressureStrategy(rxgo.Drop)).
BufferWithTime(rxgo.WithDuration(time.Millisecond * 500)) BufferWithTime(rxgo.WithDuration(time.Millisecond * 500))
) )

View File

@@ -11,20 +11,18 @@ import (
) )
func validateToken(tokenValue string) error { func validateToken(tokenValue string) error {
if tokenValue == "" { token, err := jwt.Parse(tokenValue, func(t *jwt.Token) (interface{}, error) {
return errors.New("invalid token")
}
token, _ := jwt.Parse(tokenValue, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
} }
return []byte(os.Getenv("JWT_SECRET")), nil return []byte(os.Getenv("JWT_SECRET")), nil
}) })
if err != nil {
return err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string)) expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
if err != nil { if err != nil {
return err return err
} }
@@ -39,6 +37,9 @@ func validateToken(tokenValue string) error {
return nil return nil
} }
// Authentication does NOT use http-Only cookies since there's not risk for XSS
// By exposing the server through https it's completely safe to use httpheaders
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) {
token := r.Header.Get("X-Authentication") token := r.Header.Get("X-Authentication")

View File

@@ -1,93 +0,0 @@
package middlewares
import (
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
)
type SpaHandler struct {
Entrypoint string
Filesystem fs.FS
routes []string
}
func NewSpaHandler(index string, fs fs.FS) *SpaHandler {
return &SpaHandler{
Entrypoint: index,
Filesystem: fs,
}
}
func (s *SpaHandler) AddClientRoute(route string) *SpaHandler {
s.routes = append(s.routes, route)
return s
}
// Handler for serving a compiled react frontend
// each client-side routes must be provided
func (s *SpaHandler) Handler() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(
w,
http.StatusText(http.StatusMethodNotAllowed),
http.StatusMethodNotAllowed,
)
return
}
path := filepath.Clean(r.URL.Path)
// basically all frontend routes are needed :/
hasRoute := false
for _, route := range s.routes {
hasRoute = strings.HasPrefix(path, route)
if hasRoute {
break
}
}
if path == "/" || hasRoute {
path = s.Entrypoint
}
path = strings.TrimPrefix(path, "/")
file, err := s.Filesystem.Open(path)
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
contentType := mime.TypeByExtension(filepath.Ext(path))
w.Header().Set("Content-Type", contentType)
if strings.HasPrefix(path, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=2592000")
}
stat, err := file.Stat()
if err == nil && stat.Size() > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
}
w.WriteHeader(http.StatusOK)
io.Copy(w, file)
})
}

15
server/rest/common.go Normal file
View File

@@ -0,0 +1,15 @@
package rest
import (
"database/sql"
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
type ContainerArgs struct {
DB *sql.DB
MDB *internal.MemoryDB
MQ *internal.MessageQueue
Logger *slog.Logger
}

View File

@@ -1,24 +1,21 @@
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/config"
"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 *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) *Handler { func Container(args *ContainerArgs) *Handler {
var ( var (
service = ProvideService(db, mdb, mq) service = ProvideService(args)
handler = ProvideHandler(service) handler = ProvideHandler(service)
) )
return handler return handler
} }
func ApplyRouter(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) { func ApplyRouter(args *ContainerArgs) func(chi.Router) {
h := Container(db, mdb, mq) h := Container(args)
return func(r chi.Router) { return func(r chi.Router) {
if config.Instance().RequireAuth { if config.Instance().RequireAuth {
@@ -26,12 +23,10 @@ func ApplyRouter(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue)
} }
r.Post("/exec", h.Exec()) r.Post("/exec", h.Exec())
r.Get("/running", h.Running()) r.Get("/running", h.Running())
r.Get("/version", h.GetVersion())
r.Post("/cookies", h.SetCookies()) r.Post("/cookies", h.SetCookies())
r.Post("/template", h.AddTemplate()) r.Post("/template", h.AddTemplate())
r.Get("/template/all", h.GetTemplates()) r.Get("/template/all", h.GetTemplates())
r.Delete("/template/{id}", h.DeleteTemplate()) r.Delete("/template/{id}", h.DeleteTemplate())
r.Get("/tree", h.DirectoryTree())
r.Get("/d/{id}", h.DownloadFile())
} }
} }

View File

@@ -2,10 +2,7 @@ package rest
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"os"
"path/filepath"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
@@ -15,13 +12,17 @@ type Handler struct {
service *Service service *Service
} }
/*
REST version of the JSON-RPC interface
*/
func (h *Handler) Exec() http.HandlerFunc { func (h *Handler) Exec() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
req := internal.DownloadRequest{} var req internal.DownloadRequest
if err := json.NewDecoder(r.Body).Decode(&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)
@@ -158,54 +159,20 @@ func (h *Handler) DeleteTemplate() http.HandlerFunc {
} }
} }
func (h *Handler) DirectoryTree() http.HandlerFunc { func (h *Handler) GetVersion() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
tree, err := h.service.DirectoryTree(r.Context()) version, err := h.service.GetVersion(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
err = json.NewEncoder(w).Encode(tree) if err := json.NewEncoder(w).Encode(version); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
} }
func (h *Handler) DownloadFile() 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")
path, err := h.service.DownloadFile(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add(
"Content-Disposition",
"inline; filename="+filepath.Base(*path),
)
w.Header().Set(
"Content-Type",
"application/octet-stream",
)
fd, err := os.Open(*path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.Copy(w, fd)
}
}

View File

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

View File

@@ -6,10 +6,12 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"os" "os"
"os/exec"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"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"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
) )
type Service struct { type Service struct {
@@ -120,15 +122,22 @@ func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
return err return err
} }
func (s *Service) DirectoryTree(ctx context.Context) (*internal.Stack[sys.FSNode], error) { func (s *Service) GetVersion(ctx context.Context) (string, error) {
return sys.DirectoryTree() ch := make(chan string, 1)
}
func (s *Service) DownloadFile(ctx context.Context, id string) (*string, error) { c, cancel := context.WithTimeout(ctx, time.Second*10)
p, err := s.mdb.Get(id) defer cancel()
if err != nil {
return nil, err cmd := exec.CommandContext(c, config.Instance().DownloaderPath, "--version")
go func() {
stdout, _ := cmd.Output()
ch <- string(stdout)
}()
select {
case <-c.Done():
return "", errors.New("requesting yt-dlp version took too long")
case res := <-ch:
return res, nil
} }
return &p.Output.Path, nil
} }

View File

@@ -14,6 +14,7 @@ var upgrader = websocket.Upgrader{
}, },
} }
// WebSockets JSON-RPC handler
func WebSocket(w http.ResponseWriter, r *http.Request) { func WebSocket(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil) c, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
@@ -47,6 +48,7 @@ func WebSocket(w http.ResponseWriter, r *http.Request) {
} }
} }
// HTTP-POST JSON-RPC handler
func Post(w http.ResponseWriter, r *http.Request) { func Post(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()

View File

@@ -54,7 +54,6 @@ func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) er
} }
*result = "" *result = ""
return nil return nil
} }
@@ -64,14 +63,17 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
if err != nil { if err != nil {
return err return err
} }
*progress = proc.Progress *progress = proc.Progress
return nil return nil
} }
// Progess retrieves available format for a given resource // Progess retrieves available format for a given resource
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error { func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
var err error var (
p := internal.Process{Url: args.URL, Logger: s.logger} err error
p = internal.Process{Url: args.URL, Logger: s.logger}
)
*meta, err = p.GetFormatsSync() *meta, err = p.GetFormatsSync()
return err return err
} }
@@ -91,11 +93,12 @@ func (s *Service) Running(args NoArgs, running *Running) error {
// Kill kills a process given its id and remove it from the memoryDB // Kill kills a process given its id and remove it from the memoryDB
func (s *Service) Kill(args string, killed *string) error { func (s *Service) Kill(args string, killed *string) error {
s.logger.Info("Trying killing process with id", slog.String("id", args)) s.logger.Info("Trying killing process with id", slog.String("id", args))
proc, err := s.db.Get(args)
proc, err := s.db.Get(args)
if err != nil { if err != nil {
return err return err
} }
if proc != nil { if proc != nil {
err = proc.Kill() err = proc.Kill()
s.db.Delete(proc.Id) s.db.Delete(proc.Id)
@@ -109,19 +112,24 @@ func (s *Service) Kill(args string, killed *string) error {
// the memory db // the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error { func (s *Service) KillAll(args NoArgs, killed *string) error {
s.logger.Info("Killing all spawned processes") s.logger.Info("Killing all spawned processes")
keys := s.db.Keys()
var err error var (
keys = s.db.Keys()
err error
)
for _, key := range *keys { for _, key := range *keys {
proc, err := s.db.Get(key) proc, err := s.db.Get(key)
if err != nil { if err != nil {
return err return err
} }
if proc != nil { if proc != nil {
proc.Kill() proc.Kill()
s.db.Delete(proc.Id) s.db.Delete(proc.Id)
} }
} }
s.mq.Empty()
return err return err
} }
@@ -135,27 +143,42 @@ func (s *Service) Clear(args string, killed *string) error {
// FreeSpace gets the available from package sys util // FreeSpace gets the available from package sys util
func (s *Service) FreeSpace(args NoArgs, free *uint64) error { func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
freeSpace, err := sys.FreeSpace() freeSpace, err := sys.FreeSpace()
if err != nil {
return err
}
*free = freeSpace *free = freeSpace
return err return err
} }
// Return a flattned tree of the download directory // Return a flattned tree of the download directory
func (s *Service) DirectoryTree(args NoArgs, tree *internal.Stack[sys.FSNode]) error { func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
dfsTree, err := sys.DirectoryTree() dfsTree, err := sys.DirectoryTree()
if err != nil {
*tree = nil
return err
}
if dfsTree != nil { if dfsTree != nil {
*tree = *dfsTree *tree = *dfsTree
} }
return err
return nil
} }
// Updates the yt-dlp binary using its builtin function // Updates the yt-dlp binary using its builtin function
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error { func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
s.logger.Info("Updating yt-dlp executable to the latest release") s.logger.Info("Updating yt-dlp executable to the latest release")
err := updater.UpdateExecutable()
if err != nil { if err := updater.UpdateExecutable(); err != nil {
*updated = true s.logger.Error("Failed updating yt-dlp")
*updated = false
return err return err
} }
*updated = false
return err *updated = true
s.logger.Info("Succesfully updated yt-dlp")
return nil
} }

View File

@@ -6,13 +6,16 @@ import (
"net/rpc/jsonrpc" "net/rpc/jsonrpc"
) )
// Wrapper for HTTP RPC request that implements io.Reader interface // Wrapper for jsonrpc.ServeConn that simplifies its usage
type rpcRequest struct { type rpcRequest struct {
r io.Reader r io.Reader
rw io.ReadWriter rw io.ReadWriter
done chan bool done chan bool
} }
// Takes a reader that can be an *http.Request or anthing that implements
// io.ReadWriter interface.
// Call() will perform the jsonRPC call and write or read from the ReadWriter
func newRequest(r io.Reader) *rpcRequest { func newRequest(r io.Reader) *rpcRequest {
var buf bytes.Buffer var buf bytes.Buffer
done := make(chan bool) done := make(chan bool)

View File

@@ -6,6 +6,9 @@ import "time"
// //
// 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
//
// Soon it will be deprecated since it doesn't add anything useful.
// (It lowers the CPU usage by a negligible margin)
func Sample(span time.Duration, source chan []byte, done chan struct{}, fn func(e []byte)) { func Sample(span time.Duration, source chan []byte, done chan struct{}, fn func(e []byte)) {
var ( var (
item []byte item []byte

View File

@@ -7,17 +7,19 @@ import (
"io" "io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/rpc" "net/rpc"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"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/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/dbutils" "github.com/marcopeocchi/yt-dlp-web-ui/server/dbutil"
"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"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging" "github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
@@ -28,8 +30,19 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
type RunConfig struct {
Host string
Port int
DBPath string
LogFile string
FileLogging bool
App fs.FS
Swagger fs.FS
}
type serverConfig struct { type serverConfig struct {
frontend fs.FS frontend fs.FS
swagger fs.FS
logger *slog.Logger logger *slog.Logger
host string host string
port int port int
@@ -38,36 +51,57 @@ type serverConfig struct {
mq *internal.MessageQueue mq *internal.MessageQueue
} }
func RunBlocking(host string, port int, frontend fs.FS, dbPath string) { func RunBlocking(cfg *RunConfig) {
var mdb internal.MemoryDB var mdb internal.MemoryDB
logWriters := []io.Writer{
os.Stdout,
logging.NewObservableLogger(),
}
if cfg.FileLogging {
logger, err := logging.NewRotableLogger(cfg.LogFile)
if err != nil {
panic(err)
}
go func() {
for {
time.Sleep(time.Hour * 24)
logger.Rotate()
}
}()
logWriters = append(logWriters, logger)
}
logger := slog.New( logger := slog.New(
slog.NewTextHandler( slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}),
io.MultiWriter(os.Stdout, logging.NewObservableLogger()),
nil,
),
) )
mdb.Restore(logger) db, err := sql.Open("sqlite", cfg.DBPath)
db, err := sql.Open("sqlite", dbPath)
if err != nil { if err != nil {
logger.Error("failed to open database", slog.String("err", err.Error())) logger.Error("failed to open database", slog.String("err", err.Error()))
} }
err = dbutils.AutoMigrate(context.Background(), db) if err := dbutil.AutoMigrate(context.Background(), db); err != nil {
if err != nil {
logger.Error("failed to init database", slog.String("err", err.Error())) logger.Error("failed to init database", slog.String("err", err.Error()))
} }
mq := internal.NewMessageQueue() mq, err := internal.NewMessageQueue(logger)
go mq.Subscriber() if err != nil {
panic(err)
}
mq.SetupConsumers()
go mdb.Restore(mq, logger)
srv := newServer(serverConfig{ srv := newServer(serverConfig{
frontend: frontend, frontend: cfg.App,
swagger: cfg.Swagger,
logger: logger, logger: logger,
host: host, host: cfg.Host,
port: port, port: cfg.Port,
mdb: &mdb, mdb: &mdb,
mq: mq, mq: mq,
db: db, db: db,
@@ -76,9 +110,25 @@ func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
go gracefulShutdown(srv, &mdb) go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &mdb, logger) go autoPersist(time.Minute*5, &mdb, logger)
logger.Info("yt-dlp-webui started", slog.Int("port", port)) var (
network = "tcp"
address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
)
if err := srv.ListenAndServe(); err != nil { if strings.HasPrefix(cfg.Host, "/") {
network = "unix"
address = cfg.Host
}
listener, err := net.Listen(network, address)
if err != nil {
logger.Error("failed to listen", slog.String("err", err.Error()))
return
}
logger.Info("yt-dlp-webui started", slog.String("address", address))
if err := srv.Serve(listener); err != nil {
logger.Warn("http server stopped", slog.String("err", err.Error())) logger.Warn("http server stopped", slog.String("err", err.Error()))
} }
} }
@@ -108,6 +158,7 @@ func newServer(c serverConfig) *http.Server {
// r.Use(middleware.Logger) // r.Use(middleware.Logger)
r.Mount("/", http.FileServer(http.FS(c.frontend))) r.Mount("/", http.FileServer(http.FS(c.frontend)))
r.Mount("/openapi", http.FileServer(http.FS(c.swagger)))
// Archive routes // Archive routes
r.Route("/archive", func(r chi.Router) { r.Route("/archive", func(r chi.Router) {
@@ -118,6 +169,7 @@ func newServer(c serverConfig) *http.Server {
r.Post("/delete", handlers.DeleteFile) r.Post("/delete", handlers.DeleteFile)
r.Get("/d/{id}", handlers.DownloadFile) r.Get("/d/{id}", handlers.DownloadFile)
r.Get("/v/{id}", handlers.SendFile) r.Get("/v/{id}", handlers.SendFile)
r.Get("/bulk", handlers.BulkDownload(c.mdb))
}) })
// Authentication routes // Authentication routes
@@ -130,15 +182,17 @@ 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.mdb, c.mq)) r.Route("/api/v1", rest.ApplyRouter(&rest.ContainerArgs{
DB: c.db,
MDB: c.mdb,
MQ: c.mq,
Logger: c.logger,
}))
// Logging // Logging
r.Route("/log", logging.ApplyRouter()) r.Route("/log", logging.ApplyRouter())
return &http.Server{ return &http.Server{Handler: r}
Addr: fmt.Sprintf("%s:%d", c.host, c.port),
Handler: r,
}
} }
func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) { func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {

View File

@@ -18,35 +18,43 @@ func FreeSpace() (uint64, error) {
return (stat.Bavail * uint64(stat.Bsize)), nil return (stat.Bavail * uint64(stat.Bsize)), nil
} }
type FSNode struct {
path string
children []FSNode
}
// Build a directory tree started from the specified path using DFS. // Build a directory tree started from the specified path using DFS.
// Then return the flattened tree represented as a list. // Then return the flattened tree represented as a list.
func DirectoryTree() (*internal.Stack[FSNode], error) { func DirectoryTree() (*[]string, error) {
rootPath := config.Instance().DownloadPath type Node struct {
path string
children []Node
}
stack := internal.NewStack[FSNode]() var (
rootPath = config.Instance().DownloadPath
stack.Push(FSNode{path: rootPath}) stack = internal.NewStack[Node]()
flattened = make([]string, 0)
)
stack.Push(Node{path: rootPath})
flattened = append(flattened, rootPath)
for stack.IsNotEmpty() { for stack.IsNotEmpty() {
current := stack.Pop() current := stack.Pop().Value
children, err := os.ReadDir(current.path) children, err := os.ReadDir(current.path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, entry := range children { for _, entry := range children {
childPath := filepath.Join(current.path, entry.Name()) var (
childNode := FSNode{path: childPath} childPath = filepath.Join(current.path, entry.Name())
childNode = Node{path: childPath}
)
if entry.IsDir() { if entry.IsDir() {
current.children = append(current.children, childNode) current.children = append(current.children, childNode)
stack.Push(childNode) stack.Push(childNode)
flattened = append(flattened, childNode.path)
} }
} }
} }
return stack, nil return &flattened, nil
} }

View File

@@ -1,5 +0,0 @@
package utils
const (
TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui"
)

View File

@@ -1,29 +0,0 @@
package utils
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"regexp"
"strings"
)
var (
videoRe = regexp.MustCompile(`(?i)/\.mov|\.mp4|\.webm|\.mvk|/gmi`)
)
func IsVideo(d fs.DirEntry) bool {
return videoRe.MatchString(d.Name())
}
func IsValidEntry(d fs.DirEntry) bool {
return !strings.HasPrefix(d.Name(), ".") &&
!strings.HasSuffix(d.Name(), ".part") &&
!strings.HasSuffix(d.Name(), ".ytdl")
}
func ShaSumString(path string) string {
h := sha256.New()
h.Write([]byte(path))
return hex.EncodeToString(h.Sum(nil))
}

View File

@@ -1,55 +0,0 @@
package utils
import (
"io"
"io/fs"
"os"
"path/filepath"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
func LogRotate() (*os.File, error) {
logs := findLogs()
for _, log := range logs {
logfd, err := os.Open(log)
if err != nil {
return nil, err
}
gzWriter, err := os.Create(log + ".gz")
if err != nil {
return nil, err
}
_, err = io.Copy(gzWriter, logfd)
if err != nil {
return nil, err
}
}
logfile := time.Now().String() + ".log"
config.Instance().CurrentLogFile = logfile
return os.Create(logfile)
}
func findLogs() []string {
var (
logfiles []string
root = config.Instance().LogPath
)
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if filepath.Ext(d.Name()) == ".log" {
logfiles = append(logfiles, path)
}
return nil
})
return logfiles
}

24
ui/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

View File

@@ -1,47 +0,0 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

View File

@@ -1,16 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/svelte.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yt-dlp WebUI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,31 +0,0 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"@tsconfig/svelte": "^5.0.2",
"@zerodevx/svelte-toast": "^0.9.5",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.34",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
"typescript": "^5.2.2",
"vite": "^5.0.8"
},
"dependencies": {
"@fontsource/roboto": "^5.0.8",
"fp-ts": "^2.16.2",
"lucide-svelte": "^0.323.0",
"svelte-spa-router": "^4.0.1"
}
}

1693
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,25 +0,0 @@
<script lang="ts">
import { SvelteToast } from '@zerodevx/svelte-toast';
import Router from 'svelte-spa-router';
import { wrap } from 'svelte-spa-router/wrap';
import Footer from './lib/Footer.svelte';
import Home from './views/Home.svelte';
import Navbar from './lib/Navbar.svelte';
const routes = {
'/': Home,
'/settings': wrap({
asyncComponent: () => import('./views/SettingsView.svelte'),
}),
};
</script>
<main
class="bg-neutral-50 dark:bg-neutral-900 h-screen text-neutral-950 dark:text-neutral-50"
>
<Navbar />
<Router {routes} />
<Footer />
<SvelteToast />
<!-- <FloatingAction /> -->
</main>

View File

@@ -1,7 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* body {
font-family: "Roboto";
} */

View File

@@ -1,15 +0,0 @@
<script lang="ts">
let clazz: string = '';
export let disabled: boolean = false;
export { clazz as class };
</script>
<button
class={`px-2.5 py-2 rounded-lg bg-blue-300 hover:bg-blue-400 hover:duration-150 text-sm font-semibold ${
disabled && 'bg-neutral-300 hover:bg-neutral-300'
} ${clazz}`}
{disabled}
on:click
>
<slot />
</button>

View File

@@ -1,10 +0,0 @@
<script lang="ts">
export let text: string;
</script>
<div
class="flex items-center gap-1.5 p-1 bg-blue-200 rounded-lg text-neutral-900"
>
<slot />
{text}
</div>

View File

@@ -1,137 +0,0 @@
<script lang="ts">
import { toast } from '@zerodevx/svelte-toast';
import * as A from 'fp-ts/Array';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/lib/function';
import { get } from 'svelte/store';
import { ffetch } from './ffetch';
import { cookiesTemplate, serverApiEndpoint } from './store';
import { debounce } from './utils';
const flag = '--cookies=cookies.txt';
let cookies = localStorage.getItem('cookies') ?? '';
const validateCookie = (cookie: string) =>
pipe(
cookie,
(cookie) => cookie.replace(/\s\s+/g, ' '),
(cookie) => cookie.replaceAll('\t', ' '),
(cookie) => cookie.split(' '),
E.of,
E.flatMap(
E.fromPredicate(
(f) => f.length === 7,
() => `missing parts`,
),
),
E.flatMap(
E.fromPredicate(
(f) => f[0].length > 0,
() => 'missing domain',
),
),
E.flatMap(
E.fromPredicate(
(f) => f[1] === 'TRUE' || f[1] === 'FALSE',
() => `invalid include subdomains`,
),
),
E.flatMap(
E.fromPredicate(
(f) => f[2].length > 0,
() => 'invalid path',
),
),
E.flatMap(
E.fromPredicate(
(f) => f[3] === 'TRUE' || f[3] === 'FALSE',
() => 'invalid secure flag',
),
),
E.flatMap(
E.fromPredicate(
(f) => isFinite(Number(f[4])),
() => 'invalid expiration',
),
),
E.flatMap(
E.fromPredicate(
(f) => f[5].length > 0,
() => 'invalid name',
),
),
E.flatMap(
E.fromPredicate(
(f) => f[6].length > 0,
() => 'invalid value',
),
),
);
const validateNetscapeCookies = (cookies: string) =>
pipe(
cookies,
(cookies) => cookies.split('\n'),
(cookies) => cookies.filter((f) => !f.startsWith('\n')), // empty lines
(cookies) => cookies.filter((f) => !f.startsWith('# ')), // comments
(cookies) => cookies.filter(Boolean), // empty lines
A.map(validateCookie),
A.mapWithIndex((i, either) =>
pipe(
either,
E.matchW(
(l) => toast.push(`Error in line ${i + 1}: ${l}`),
() => E.isRight(either),
),
),
),
A.filter(Boolean),
A.match(
() => false,
(c) => {
toast.push(`Valid ${c.length} Netscape cookies`);
return true;
},
),
);
const submitCookies = (cookies: string) =>
ffetch(`${get(serverApiEndpoint)}/api/v1/cookies`, {
method: 'POST',
body: JSON.stringify({
cookies,
}),
})();
const execute = (cookies: KeyboardEvent) =>
pipe(
cookies.target as HTMLTextAreaElement,
(cookies) => cookies.value,
O.fromPredicate(validateNetscapeCookies),
O.match(
() => cookiesTemplate.set(''),
async (cookies) => {
pipe(
await submitCookies(cookies),
E.match(
(l) => toast.push(l),
() => {
toast.push(`Saved Netscape cookies`);
cookiesTemplate.set(flag);
localStorage.setItem('cookies', cookies);
},
),
);
},
),
);
</script>
<textarea
cols="80"
rows="8"
value={cookies}
on:keyup={debounce(execute, 500)}
/>

View File

@@ -1,83 +0,0 @@
<script lang="ts">
import { get } from 'svelte/store';
import Button from './Button.svelte';
import Chip from './Chip.svelte';
import { rpcClient, serverApiEndpoint } from './store';
import type { RPCResult } from './types';
import { formatSpeedMiB, roundMiB } from './utils';
export let download: RPCResult;
const remove = (id: string) => get(rpcClient).kill(id);
</script>
<div
class="flex gap-4
bg-neutral-100 dark:bg-neutral-800
p-2 md:p-4
rounded-lg shadow-lg
border dark:border-neutral-700"
>
<div
class="h-full hidden sm:block w-96 bg-cover bg-center rounded"
style="background-image: url({download.info.thumbnail})"
/>
<div class="flex flex-col justify-between gap-2 w-full">
<div>
<h2 class="font-bold text-lg">{download.info.title}</h2>
<p
class="font-mono text-sm mt-2 p-1 break-all bg-neutral-200 dark:bg-neutral-700 rounded"
>
{download.info.url}
</p>
</div>
<div class="flex flex-col justify-end gap-2 select-none flex-wrap">
<div class="hidden sm:flex items-center gap-2 text-sm">
{#if download.info.vcodec}
<Chip text={download.info.vcodec} />
{/if}
{#if download.info.acodec}
<Chip text={download.info.acodec} />
{/if}
{#if download.info.ext}
<Chip text={download.info.ext} />
{/if}
{#if download.info.resolution}
<Chip text={download.info.resolution} />
{/if}
{#if download.info.filesize_approx}
<Chip text={roundMiB(download.info.filesize_approx)} />
{/if}
<!-- {#if download.progress.process_status}
<Chip text={mapProcessStatus(download.progress.process_status)} />
{/if} -->
{#if download.progress.speed}
<Chip text={formatSpeedMiB(download.progress.speed)} />
{/if}
</div>
<div class="flex gap-2">
<Button class="w-14" on:click={() => remove(download.id)}>Stop</Button>
{#if download.progress.process_status === 2}
<Button class="w-18">Download</Button>
<!-- <a href={`${$serverApiEndpoint}/api/v1/d/${download.id}`}>d</a> -->
{/if}
</div>
<div
class="w-full mt-4 h-2 rounded-full bg-neutral-200 dark:bg-neutral-700"
>
<div
class={`h-2 rounded-full ${
download.progress.process_status === 2
? 'bg-green-600'
: 'bg-blue-500'
}`}
style="width: {download.progress.percentage}"
/>
</div>
</div>
</div>
</div>

View File

@@ -1,23 +0,0 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
</script>
<div class="absolute bottom-10 right-10">
<!-- <div class="relative mb-4 flex flex-col justify-center items-center gap-2">
<button
class="relative flex items-center justify-center bg-blue-500 h-8 w-8 z-10 rounded-2xl shadow-xl text-neutral-100"
>
<Plus size={18} />
</button>
<button
class="relative flex items-center justify-center bg-blue-500 h-8 w-8 z-10 rounded-2xl shadow-xl text-neutral-100"
>
<Plus size={18} />
</button>
</div> -->
<button
class="relative bg-blue-500 p-5 z-10 rounded-2xl shadow-xl text-neutral-100"
>
<Plus />
</button>
</div>

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import { ChevronDown, ChevronUp } from 'lucide-svelte';
import { cubicOut } from 'svelte/easing';
import { tweened } from 'svelte/motion';
import NewDownload from './NewDownload.svelte';
const height = tweened(52, {
duration: 300,
easing: cubicOut,
});
const minHeight = 52;
const maxHeight = window.innerHeight / 1.5;
let open = false;
$: open = $height > minHeight;
</script>
<footer
class="
fixed bottom-0 z-10
w-full
p-2
bg-neutral-100 dark:bg-neutral-800
border-t dark:border-t-neutral-700
shadow-lg
rounded-t-xl"
style="min-height: {$height}px;"
>
<button
class="p-1 bg-neutral-200 dark:bg-neutral-700 rounded-lg border dark:border-neutral-700"
on:click={() => (open ? height.set(minHeight) : height.set(maxHeight))}
>
{#if open}
<ChevronDown />
{:else}
<ChevronUp />
{/if}
</button>
<div />
{#if $height > 100}
<div class="mt-2">
<NewDownload />
</div>
{/if}
</footer>

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import type { DLFormat } from './types';
let group = '';
export let formats: DLFormat[];
$: console.log(group);
</script>
<div class="w-full mt-4">
<div class="mx-auto w-full">
<fieldset class="grid grid-cols-7 gap-2">
{#each formats as format}
<div class="relative">
<input
id="formats"
class="absolute opacity-0 w-0 h-0 peer"
type="radio"
bind:group
name="type"
value="formats"
/>
<label
for="formats"
class="
[&_p]:text-gray-900 [&_span]:text-gray-500
peer-checked:[&_p]:text-white peer-checked:[&_span]:text-blue-100
peer-focus:ring-2 peer-focus:ring-white
peer-focus:ring-opacity-60 peer-focus:ring-offset-2 peer-focus:ring-offset-blue-300
bg-white
relative flex
cursor-pointer
rounded-lg px-5 py-4
shadow-md
focus:outline-none
peer-checked:bg-blue-700/75
peer-checked:text-white"
>
<div class="flex w-full items-center justify-between">
<div class="flex items-center">
<div class="text-sm">
<p class="font-medium" id={format.format_id}>
{format.resolution}
</p>
<span class="inline" id={format.format_id}>
<span>{format.vcodec}</span>
<span aria-hidden="true">·</span>
<span>{format.acodec}</span>
</span>
</div>
</div>
<div class="shrink-0 text-white">
<svg viewBox="0 0 24 24" fill="none" class="h-6 w-6">
<circle cx="12" cy="12" r="12" fill="#fff" opacity="0.2" />
<path
d="M7 13l3 3 7-7"
stroke="#fff"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</label>
</div>
{/each}
</fieldset>
</div>
</div>

View File

@@ -1,9 +0,0 @@
<script lang="ts">
import Spinner from './Spinner.svelte';
</script>
<div
class="top-0 left-0 absolute w-full h-full bg-neutral-950/20 flex items-center justify-center z-50"
>
<Spinner />
</div>

View File

@@ -1,97 +0,0 @@
<script lang="ts">
import {
ArrowDownUp,
Github,
HardDrive,
Network,
Settings,
} from 'lucide-svelte';
import { downloads, rpcClient, serverApiEndpoint } from './store';
import { formatGiB, formatSpeedMiB } from './utils';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/lib/function';
import { onDestroy } from 'svelte';
import { link } from 'svelte-spa-router';
let downloadSpeed = 0;
const unsubscribe = downloads.subscribe((downloads) =>
pipe(
downloads,
O.matchW(
() => (downloadSpeed = 0),
(d) =>
(downloadSpeed = d
.map((d) => d.progress.speed)
.reduce((a, b) => a + b)),
),
),
);
onDestroy(unsubscribe);
</script>
<nav
class="
p-4
flex justify-between items-center
bg-neutral-100 dark:bg-neutral-800
rounded-b-xl
border-b dark:border-b-neutral-700
shadow-lg
select-none"
>
<a use:link={'/'} href="/" class="font-semibold text-lg">yt-dlp WebUI</a>
<div />
<div class="flex items-center gap-2 text-sm">
<div
class="hidden sm:flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
>
<ArrowDownUp size={18} />
<div>
{formatSpeedMiB(downloadSpeed)}
</div>
</div>
<div class="flex items-center gap-2 text-sm">
<div
class="flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
>
<HardDrive size={18} />
<div>
{#await $rpcClient.freeSpace()}
Loading...
{:then freeSpace}
{formatGiB(freeSpace.result)}
{/await}
</div>
</div>
<div
class="flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
>
<Network size={18} />
<div>
{$serverApiEndpoint.split('//')[1]}
</div>
</div>
<a
href="https://github.com/marcopeocchi/yt-dlp-web-ui"
class="flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
>
<Github size={18} />
</a>
<a
use:link={'/settings'}
href="/settings"
class="flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
>
<Settings size={18} />
</a>
</div>
</div>
</nav>

View File

@@ -1,49 +0,0 @@
<script lang="ts">
import { get } from 'svelte/store';
import Button from './Button.svelte';
import TextField from './TextField.svelte';
import { downloadTemplates, rpcClient } from './store';
import Select from './Select.svelte';
import type { DLMetadata } from './types';
import FormatsList from './FormatsList.svelte';
let url: string = '';
let args: string = '';
let metadata: DLMetadata;
const download = () =>
get(rpcClient).download({
url,
args,
});
const getFormats = () =>
get(rpcClient)
.formats(url)
?.then((f) => (metadata = f.result));
</script>
<div class="w-full px-8">
<div class="my-4 font-semibold text-xl">New download</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 w-full mb-2">
<TextField placeholder="https://..." label="URL" bind:value={url} />
<TextField
placeholder="arguments separated by space"
label="yt-dlp arguments"
bind:value={args}
/>
<Select bind:value={args}>
<option selected disabled value=""> Select download template </option>
{#each $downloadTemplates as template}
<option id={template.id} value={template.content}>
{template.name}
</option>
{/each}
</Select>
</div>
<Button class="mt-2" on:click={download}>Download</Button>
<Button class="mt-2" on:click={getFormats}>Select format</Button>
{#if metadata}
<FormatsList formats={metadata.formats} />
{/if}
</div>

View File

@@ -1,195 +0,0 @@
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from './types'
type DownloadRequestArgs = {
url: string,
args: string,
pathOverride?: string,
renameTo?: string,
playlist?: boolean
}
export class RPCClient {
private seq: number
private httpEndpoint: string
private readonly _socket$: WebSocket
private readonly token?: string
constructor(httpEndpoint: string, webSocketEndpoint: string, token?: string) {
this.seq = 0
this.httpEndpoint = httpEndpoint
this.token = token
this._socket$ = new WebSocket(
token ? `${webSocketEndpoint}?token=${token}` : webSocketEndpoint
)
}
/**
* Websocket connection
*/
public get socket() {
return this._socket$
}
private incrementSeq() {
return String(this.seq++)
}
private send(req: RPCRequest) {
this._socket$.send(JSON.stringify({
...req,
id: this.incrementSeq(),
}))
}
private argsSanitizer(args: string) {
return args
.split(' ')
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
.filter(Boolean)
}
private async sendHTTP<T>(req: RPCRequest) {
const res = await fetch(this.httpEndpoint, {
method: 'POST',
headers: {
'X-Authentication': this.token ?? ''
},
body: JSON.stringify({
...req,
id: this.incrementSeq(),
})
})
const data: RPCResponse<T> = await res.json()
return data
}
/**
* Request a new download. Handles arguments sanitization.
* @param req payload
* @returns
*/
public download(req: DownloadRequestArgs) {
if (!req.url) {
return
}
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({
method: 'Service.ExecPlaylist',
params: [{
URL: req.url,
Params: sanitizedArgs,
Path: req.pathOverride,
Rename: req.renameTo || rename,
}]
})
}
this.sendHTTP({
method: 'Service.Exec',
params: [{
URL: req.url.split('?list').at(0)!,
Params: sanitizedArgs,
Path: req.pathOverride,
Rename: req.renameTo || rename,
}]
})
}
/**
* Requests the available formats for a given url (-f arg)
* @param url requested url
* @returns
*/
public formats(url: string) {
if (url) {
return this.sendHTTP<DLMetadata>({
method: 'Service.Formats',
params: [{
URL: url.split('?list').at(0)!,
}]
})
}
}
/**
* Requests all downloads
*/
public running() {
this.send({
method: 'Service.Running',
params: [],
})
}
/**
* Stops and removes a download asynchronously
* @param id download id
*/
public kill(id: string) {
this.sendHTTP({
method: 'Service.Kill',
params: [id],
})
}
/**
* Stops and removes all downloads
*/
public killAll() {
this.sendHTTP({
method: 'Service.KillAll',
params: [],
})
}
/**
* Get asynchronously the avaliable space on downloads directory
* @returns free space in bytes
*/
public freeSpace() {
return this.sendHTTP<number>({
method: 'Service.FreeSpace',
params: [],
})
}
/**
* Get asynchronously the tree view of the download directory
* @returns free space in bytes
*/
public directoryTree() {
return this.sendHTTP<string[]>({
method: 'Service.DirectoryTree',
params: [],
})
}
/**
* Updates synchronously yt-dlp executable
* @returns free space in bytes
*/
public updateExecutable() {
return this.sendHTTP({
method: 'Service.UpdateExecutable',
params: []
})
}
}

View File

@@ -1,23 +0,0 @@
<script lang="ts">
export let value: any;
export let disabled: boolean = false;
export let placeholder: string = '';
export { clazz as class };
</script>
<select
class="
p-2
bg-neutral-50
border rounded-lg
appearance-none
text-sm font-semibold
focus:outline-blue-300
"
bind:value
{disabled}
{placeholder}
>
<slot />
</select>

Some files were not shown because too many files have changed in this diff Show More