Compare commits

...

135 Commits

Author SHA1 Message Date
3edebbdb6c prevent completed download restart 2024-07-02 11:37:06 +02:00
Marco Piovanello
98f0ea3bd2 fix typo
closes #162
2024-06-30 10:37:22 +02:00
0daf36719b Fixed process not cleared after download.
Closes  #160
2024-06-21 10:41:49 +02:00
38683bfe85 code refactoring 2024-06-14 11:14:39 +02:00
4066a6d5e3 frontend code refactoring 2024-06-14 10:42:25 +02:00
ee18929196 missing psmisc docker file 2024-06-14 10:41:59 +02:00
9c09c88d06 way faster playlist entries detection 2024-06-12 10:16:24 +02:00
d402d71815 code refactoring, dependencies upgrade 2024-06-12 10:15:50 +02:00
848f716d08 Updated env.nix 2024-06-11 16:25:49 +02:00
14a14a9f38 Added env.nix 2024-06-11 16:21:37 +02:00
6ffca7d64f sse logger consumer optimizations 2024-06-11 16:18:04 +02:00
0b0ba4718c Kubernetes fixups according to #157 2024-06-09 11:21:13 +02:00
7ea1c0b205 small layout changes in downloads table 2024-06-07 22:24:25 +02:00
d614433501 fixed yt-dlp duplicated playlist entries 2024-06-07 16:24:36 +02:00
c108428243 virtualized downloads table 2024-06-07 15:21:29 +02:00
f4c4d6928b code refactoring 2024-06-07 11:19:17 +02:00
00ca9156fb sped-up download by spawning 1 less yt-dlp process 2024-06-07 10:55:03 +02:00
2f0afe27cc removed --no-mtime and -x switches in settings
Custom templates are powerful: --no-mtime has been set as default behavior (template named default), -x is another template named "audio only"
2024-06-07 10:08:32 +02:00
acac2f41a5 missing token in zip download 2024-06-07 09:26:30 +02:00
9cbce3b66c added rpc polling time selector 2024-06-05 11:15:01 +02:00
fad2f1d0da updated makefile 2024-06-05 11:14:51 +02:00
a331329125 updated version number 2024-06-05 11:14:44 +02:00
1138e66bc7 reverted base image to alpine, updated makefile 2024-06-05 09:14:59 +02:00
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
Marco
b9b0fde520 Update README.md 2024-02-05 13:40:50 +01:00
deluxghost
6e123c319f i18n: chinese (#133) 2024-02-05 08:42:26 +01:00
Calm Zhu
de975f758f bugfix: port config in config file not work (#132)
* Update main.go

* update

* Update main.go

fix lint
2024-01-31 19:37:50 +01:00
d3371ed64c handle -P or --paths yt-dlp flag 2024-01-26 11:36:39 +01:00
15766bd016 handle -P param 2024-01-26 11:33:22 +01:00
c78b3ae174 introduced millionjs compiler 2024-01-16 13:24:08 +01:00
3d9a7e9810 fixed nil pointer dereferece
closes #128
2024-01-12 10:55:29 +01:00
Marco
8aeffb8d9f Update README.md 2024-01-10 22:43:41 +01:00
Marco
7904904a37 Update README.md 2024-01-09 14:49:01 +01:00
Marco
6aa2d41988 Logging in webUI, Archive view refactor (#127)
* test logging

* test impl for logging

* implemented "live logging", restyle templates dropdown

* moved extract audio to downloadDialog, fixed labels

* code refactoring

* buffering logs
2024-01-09 14:29:18 +01:00
de1d9e6a3c added german, code refactoring 2023-12-30 11:06:00 +01:00
Baipyrus
1b46f0dd03 added german language data to frontend (#120) 2023-12-30 10:20:43 +01:00
Marco
8870602268 Update README.md 2023-12-28 11:15:52 +01:00
Marco
15d9d261a3 Update README.md 2023-12-27 15:47:03 +01:00
Marco
f3302c17cc 115 download button (#119)
* code refactoring, added file download

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

* handle template in playlist download

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

View File

@@ -1,17 +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

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
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19
with:
cosign-release: 'v1.13.1'
cosign-release: 'v1.13.6'
- name: Set up QEMU for ARM emulation
# v2.2.0
@@ -68,7 +68,7 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels}}

8
.gitignore vendored
View File

@@ -1,6 +1,4 @@
dist
package-lock.json
pnpm-lock.yaml
.pnpm-debug.log
node_modules
.env
@@ -15,3 +13,9 @@ yt-dlp-webui
session.dat
config.yml
cookies.txt
__debug*
ui/
.idea
frontend/.pnp.cjs
frontend/.pnp.loader.mjs
frontend/.yarn/install-state.gz

4
.vscode/launch.json vendored
View File

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

View File

@@ -1,29 +1,42 @@
FROM golang:1.20-alpine AS build
RUN apk update && \
apk add nodejs npm go
# Node (pnpm) ------------------------------------------------------------------
FROM node:20-slim AS ui
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /usr/src/yt-dlp-webui
WORKDIR /usr/src/yt-dlp-webui/frontend
RUN npm install
RUN npm run build
RUN rm -rf node_modules
RUN pnpm install
RUN pnpm run build
# -----------------------------------------------------------------------------
# Go --------------------------------------------------------------------------
FROM golang AS build
WORKDIR /usr/src/yt-dlp-webui
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
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 alpine:edge
RUN apk update && \
apk add ffmpeg yt-dlp ca-certificates curl wget psmisc
VOLUME /downloads /config
WORKDIR /app
RUN apk update && \
apk add psmisc ffmpeg yt-dlp --no-cache
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret
EXPOSE 3033
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml" ]
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]

View File

@@ -1,15 +1,25 @@
.PHONY : fe clean all
default:
CGO_ENABLED=0 go build -o yt-dlp-webui main.go
go run main.go
fe:
cd frontend && pnpm build
dev:
cd frontend && pnpm dev
all:
cd frontend && pnpm build && cd ..
$(MAKE) fe && cd ..
CGO_ENABLED=0 go build -o yt-dlp-webui main.go
multiarch:
$(MAKE) fe
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=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:
rm -rf build
rm -rf build

View File

@@ -1,3 +1,8 @@
> [!IMPORTANT]
> Major frontend refactoring in progress.
> I won't add features or fix minor issues until completition.
---
# yt-dlp Web UI
A not so terrible web ui for yt-dlp.
@@ -15,12 +20,13 @@ The bottleneck remains yt-dlp startup time.
docker pull marcobaobao/yt-dlp-webui
```
```sh
# latest stable
# latest dev
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
# latest dev version
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
```
[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/782c559a-f552-40be-a6fd-10e22f38e85d)
@@ -75,20 +81,10 @@ The currently avaible settings are:
## Format selection
![fs1](https://i.ibb.co/8dgS6ym/image.png)
This feature is disabled by default as this intended to be used to retrieve the best quality automatically.
To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag!
Future releases will have:
- ~~Multi download~~ *done*
- ~~Exctract audio~~ *done*
- ~~Format selection~~ *done*
- ~~Download archive~~ *done*
- ~~ARM Build~~ *done available through ghcr.io*
- Playlist support
## Troubleshooting
- **It says that it isn't connected/ip in the header is not defined.**
- You must set the server ip address in the settings section (gear icon).
@@ -96,8 +92,8 @@ Future releases will have:
- 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 run
```sh
# recomended for ARM and x86 devices
docker pull marcobaobao/yt-dlp-webui
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
```
@@ -140,6 +136,20 @@ docker run -d \
--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
```sh
@@ -167,6 +177,8 @@ Usage yt-dlp-webui:
yt-dlp executable path (default "yt-dlp")
-out string
Where files will be saved (default ".")
-host string
Host where server will listen at (default "0.0.0.0")
-port int
Port where server will listen at (default 3033)
-qs int
@@ -185,16 +197,31 @@ The config file **will overwrite what have been passed as cli argument**.
# Simple configuration file for yt-dlp webui
---
port: 8989
downloadPath: /home/ren/archive
downloaderPath: /usr/local/bin/yt-dlp
# Host where server will listen at (default: "0.0.0.0")
#host: 0.0.0.0
# 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
username: my_username
password: my_random_secret
# [optional] The download queue size (default: 8)
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
@@ -217,6 +244,29 @@ WantedBy=multi-user.target
systemctl enable 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
```sh
@@ -228,6 +278,9 @@ npm run build
go build -o yt-dlp-webui main.go
```
## Open-API
Navigate to `/openapi` to see the related swagger.
## Extendable
You dont'like the Material feel?
@@ -248,12 +301,5 @@ Just as an overview, these are the available methods:
For more information open an issue on GitHub and I will provide more info ASAP.
## FAQ
- **Will it availabe for Raspberry Pi/ generic ARM devices?**
- Yes, it's cross platform :)
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
- **Why the docker image is so heavy?**
- Originally it was 1.8GB circa, now it has been slimmed to ~340MB compressed. This is due to the fact that it encapsule a basic Alpine linux image + FFmpeg + Node.js + Python3 + yt-dlp.
- **Update**: Since Golang migration and Multi-Stage builds the Docker image is now 75MB circa. A reduction of over 400% in size :D.
## What yt-dlp-webui is not
`yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS.

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
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
- <your dir>:/config # directory where config.yml will be stored
healthcheck:
test: curl -f http://localhost:3033 || exit 1
restart: unless-stopped

4
env.nix Normal file
View File

@@ -0,0 +1,4 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs.buildPackages; [ yt-dlp nodejs_22 yarn-berry go ];
}

5
examples/Caddyfile Normal file
View File

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

37
examples/nginx.conf Normal file
View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "yt-dlp-webui",
"version": "2.10.0",
"version": "3.1.0",
"description": "Frontend compontent of yt-dlp-webui",
"scripts": {
"dev": "vite",
@@ -8,29 +8,32 @@
},
"author": "marcopeocchi",
"license": "MPL-2.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.6",
"@mui/icons-material": "^5.11.16",
"@mui/material": "^5.13.5",
"fp-ts": "^2.16.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-router-dom": "^6.17.0",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@fontsource/roboto": "^5.0.13",
"@fontsource/roboto-mono": "^5.0.18",
"@mui/icons-material": "^5.15.16",
"@mui/material": "^5.15.16",
"fp-ts": "^2.16.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"react-virtuoso": "^4.7.11",
"recoil": "^0.7.7",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@types/node": "^20.8.7",
"@types/react": "^18.2.29",
"@types/react-dom": "^18.2.14",
"@types/react-helmet": "^6.1.8",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/node": "^20.14.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.2.18",
"@types/react-helmet": "^6.1.11",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.4.0",
"typescript": "^5.2.2",
"vite": "^4.5.0"
"@vitejs/plugin-react-swc": "^3.7.0",
"million": "^3.1.11",
"typescript": "^5.4.5",
"vite": "^5.2.11"
}
}

2155
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,7 +1,7 @@
---
languages:
english:
urlInput: YouTube or other supported service video URL
urlInput: Video URL (one per line)
statusTitle: Status
statusReady: Ready
selectFormatButton: Select format
@@ -34,8 +34,75 @@ languages:
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
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
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
rpcPollingTimeTitle: RPC polling time
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
german:
urlInput: Video URL
statusTitle: Status
statusReady: Bereit
selectFormatButton: Format auswählen
startButton: Start
abortAllButton: Alle Abbrechen
updateBinButton: yt-dlp Binärdatei aktualisieren
darkThemeButton: Dunkel Modus
lightThemeButton: Hell Modus
settingsAnchor: Einstellungen
serverAddressTitle: Server Adresse
serverPortTitle: Port
extractAudioCheckbox: Audio extrahieren
noMTimeCheckbox: Datei-Änderungszeitpunkt nicht festlegen
bgReminder: Sobald Sie diese Seite schließen, wird der Download im Hintergrund fortgesetzt.
toastConnected: 'Verbunden mit '
toastUpdated: yt-dlp Binärdatei aktualisiert!
formatSelectionEnabler: Video/Audio Format auswählbar
themeSelect: 'Modus'
languageSelect: 'Sprache'
overridesAnchor: Überschreibungen
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
customFilename: Custom filemame (leave blank to use default)
customPath: Benutzerdefinierter Pfad
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
customArgsInput: Benutzerdefinierte yt-dlp Argumente
rpcConnErr: Fehler beim Verbinden mit RPC Server
splashText: Keine aktiven Downloads
archiveTitle: Archiv
clipboardAction: URL in Zwischenablage kopiert
playlistCheckbox: Playlist herunterladen (es wird einige Zeit dauern, nach dem Absenden können Sie dieses Fenster schließen)
restartAppMessage: Erfordert ein Neuladen der Seite, um wirksam zu werden
servedFromReverseProxyCheckbox: Ist hinter einem Reverse Proxy Unterordner
newDownloadButton: Neuer Download
homeButtonLabel: Home
archiveButtonLabel: Archiv
settingsButtonLabel: Einstellungen
rpcAuthenticationLabel: RPC Authentifizierung
themeTogglerLabel: Modus Umschalter
loadingLabel: Lädt...
appTitle: App Titel
savedTemplates: Gespeicherte Vorlage
templatesEditor: Vorlagen Bearbeiter
templatesEditorNameLabel: Vorlagen Name
templatesEditorContentLabel: Vorlagen Inhalt
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut
@@ -71,9 +138,25 @@ languages:
playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée)
restartAppMessage: Nécessite un rechargement de la page pour prendre effet
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
notConnectedText: not connected
settingsLabel: Settings
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: Nom de l'application
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
italian:
urlInput: URL di YouTube o di qualsiasi altro servizio supportato
urlInput: URL Video (uno per linea)
statusTitle: Stato
startButton: Inizia
statusReady: Pronto
@@ -105,10 +188,24 @@ languages:
clipboardAction: URL copiato negli appunti
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
servedFromReverseProxyCheckbox: Is behind a reverse proxy
newDownloadButton: Nuovo download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: Titolo applicazione
savedTemplates: Template salvati
templatesEditor: Editor template
templatesEditorNameLabel: Nome template
templatesEditorContentLabel: Contentunto template
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
chinese:
urlInput: YouTube 或其他受支持服务的视频网址
urlInput: 视频 URL
statusTitle: 状态
statusReady: 就绪
selectFormatButton: 选择格式
@@ -142,7 +239,21 @@ languages:
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
restartAppMessage: 需要刷新页面才能生效
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
newDownloadButton: 新下载
homeButtonLabel: 主页
archiveButtonLabel: 归档
settingsButtonLabel: 设置
rpcAuthenticationLabel: RPC 身份验证
themeTogglerLabel: 主题切换
loadingLabel: 正在加载…
appTitle: App 标题
savedTemplates: 保存模板
templatesEditor: 模板编辑器
templatesEditorNameLabel: 模板名称
templatesEditorContentLabel: 模板内容
logsTitle: '日志'
awaitingLogs: '正在等待日志…'
bulkDownload: 'Download files in a zip archive'
spanish:
urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado
@@ -176,7 +287,21 @@ languages:
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус
@@ -210,7 +335,21 @@ languages:
clipboardAction: URL скопирован в буфер обмена
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태
@@ -244,7 +383,21 @@ languages:
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
japanese:
urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態
@@ -279,7 +432,21 @@ languages:
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
catalan:
urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat
@@ -313,7 +480,21 @@ languages:
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус
@@ -347,7 +528,21 @@ languages:
clipboardAction: URL скопійовано в буфер обміну
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status
@@ -381,4 +576,18 @@ languages:
clipboardAction: Adres URL zostanie skopiowany do schowka
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'

View File

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

View File

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

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

@@ -1,12 +1,23 @@
import { selector } from 'recoil'
import { atom, selector } from 'recoil'
import { RPCClient } from '../lib/rpcClient'
import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings'
export const rpcClientState = selector({
key: 'rpcClientState',
get: ({ get }) =>
new RPCClient(get(rpcHTTPEndpoint), get(rpcWebSocketEndpoint)),
set: ({ get }) =>
new RPCClient(get(rpcHTTPEndpoint), get(rpcWebSocketEndpoint)),
new RPCClient(
get(rpcHTTPEndpoint),
get(rpcWebSocketEndpoint),
localStorage.getItem('token') ?? ''
),
dangerouslyAllowMutability: true,
})
export const rpcPollingTimeState = atom({
key: 'rpcPollingTimeState',
default: Number(localStorage.getItem('rpc-polling-time')) || 1000,
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('rpc-polling-time', a.toString()))
]
})

View File

@@ -12,6 +12,7 @@ export const languages = [
'catalan',
'ukrainian',
'polish',
'german'
] as const
export type Language = (typeof languages)[number]
@@ -63,8 +64,7 @@ export const serverAddressState = atom<string>({
export const serverPortState = atom<number>({
key: 'serverPortState',
default: Number(localStorage.getItem('server-port')) ||
Number(window.location.port),
default: Number(localStorage.getItem('server-port')) || Number(window.location.port),
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('server-port', a.toString()))
@@ -73,7 +73,7 @@ export const serverPortState = atom<number>({
export const latestCliArgumentsState = atom<string>({
key: 'latestCliArgumentsState',
default: localStorage.getItem('cli-args') || '',
default: localStorage.getItem('cli-args') || '--no-mtime',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('cli-args', a.toString()))
@@ -127,13 +127,22 @@ export const listViewState = atom({
export const servedFromReverseProxyState = atom({
key: 'servedFromReverseProxyState',
default: localStorage.getItem('reverseProxy') === "true",
default: localStorage.getItem('reverseProxy') === "true" || window.location.port == "",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxy', a.toString()))
]
})
export const servedFromReverseProxySubDirState = atom<string>({
key: 'servedFromReverseProxySubDirState',
default: localStorage.getItem('reverseProxySubDir') ?? '',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxySubDir', a))
]
})
export const appTitleState = atom({
key: 'appTitleState',
default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI',
@@ -145,15 +154,21 @@ export const appTitleState = atom({
export const serverAddressAndPortState = selector({
key: 'serverAddressAndPortState',
get: ({ get }) => get(servedFromReverseProxyState)
? `${get(serverAddressState)}`
: `${get(serverAddressState)}:${get(serverPortState)}`
get: ({ get }) => {
if (get(servedFromReverseProxySubDirState)) {
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
}
if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}`
}
return `${get(serverAddressState)}:${get(serverPortState)}`
}
})
export const serverURL = selector({
key: 'serverURL',
get: ({ get }) =>
`${window.location.protocol}//${get(serverAddressState)}:${get(serverPortState)}`
`${window.location.protocol}//${get(serverAddressAndPortState)}`
})
export const rpcWebSocketEndpoint = selector({
@@ -181,7 +196,7 @@ export const cookiesState = atom({
]
})
export const themeSelector = selector<ThemeNarrowed>({
const themeSelector = selector<ThemeNarrowed>({
key: 'themeSelector',
get: ({ get }) => {
const theme = get(themeState)

View File

@@ -1,40 +1,25 @@
import { atom, selector } from 'recoil'
import { rpcClientState } from './rpc'
type StatusState = {
connected: boolean,
updated: boolean,
downloading: boolean,
}
export const connectedState = atom({
key: 'connectedState',
default: false
})
export const updatedBinaryState = atom({
key: 'updatedBinaryState',
default: false
export const freeSpaceBytesState = selector({
key: 'freeSpaceBytesState',
get: async ({ get }) => {
const res = await get(rpcClientState).freeSpace()
.catch(() => ({ result: 0 }))
return res.result
}
})
export const isDownloadingState = atom({
key: 'isDownloadingState',
default: false
})
// export const freeSpaceBytesState = selector({
// key: 'freeSpaceBytesState',
// get: async ({ get }) => {
// const res = await get(rpcClientState).freeSpace()
// return res.result
// }
// })
export const availableDownloadPathsState = selector({
key: 'availableDownloadPathsState',
get: async ({ get }) => {
const res = await get(rpcClientState).directoryTree()
.catch(() => ({ result: [] }))
return res.result
}
})

View File

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

View File

@@ -1,6 +1,14 @@
import { atom } from 'recoil'
import { atom, selector } from 'recoil'
import { activeDownloadsState } from './downloads'
export const loadingAtom = atom({
key: 'loadingAtom',
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

@@ -1,11 +1,11 @@
import { styled } from '@mui/material';
import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar';
import { styled } from '@mui/material'
import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'
interface AppBarProps extends MuiAppBarProps {
open?: boolean
}
const drawerWidth = 240;
const drawerWidth = 240
const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== 'open',
@@ -23,6 +23,6 @@ const AppBar = styled(MuiAppBar, {
duration: theme.transitions.duration.enteringScreen,
}),
}),
}));
}))
export default AppBar

View File

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

View File

@@ -15,8 +15,11 @@ import {
Stack,
Typography
} from '@mui/material'
import { useCallback } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { RPCResult } from '../types'
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils'
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
type Props = {
download: RPCResult
@@ -34,11 +37,29 @@ const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
}
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
const isCompleted = () => download.progress.percentage === '-1'
const serverAddr = useRecoilValue(serverURL)
const percentageToNumber = () => isCompleted()
? 100
: Number(download.progress.percentage.replace('%', ''))
const isCompleted = useCallback(
() => download.progress.percentage === '-1',
[download.progress.percentage]
)
const percentageToNumber = useCallback(
() => isCompleted()
? 100
: Number(download.progress.percentage.replace('%', '')),
[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 (
<Card>
@@ -54,14 +75,22 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
/> :
<Skeleton variant="rectangular" height={180} />
}
{download.progress.percentage ?
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted() ? "success" : "primary"}
/> :
null
}
<CardContent>
{download.info.title !== '' ?
<Typography gutterBottom variant="h6" component="div">
{ellipsis(download.info.title, 54)}
{ellipsis(download.info.title, 100)}
</Typography> :
<Skeleton />
}
<Stack direction="row" spacing={1} py={2}>
<Stack direction="row" spacing={0.5} py={1}>
<Chip
label={
isCompleted()
@@ -79,18 +108,10 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
{!isCompleted() ? formatSpeedMiB(download.progress.speed) : ''}
</Typography>
<Typography>
{roundMiB(download.info.filesize_approx ?? 0)}
{formatSize(download.info.filesize_approx ?? 0)}
</Typography>
<Resolution resolution={download.info.resolution} />
</Stack>
{download.progress.percentage ?
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted() ? "secondary" : "primary"}
/> :
null
}
</CardContent>
</CardActionArea>
<CardActions>
@@ -102,6 +123,26 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
>
{isCompleted() ? "Clear" : "Stop"}
</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>
</Card>
)

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
import { Grid } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import DownloadCard from './DownloadCard'
const DownloadsCardView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? []
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const abort = (id: string) => client.kill(id)
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
{
downloads.map(download => (
<Grid item xs={4} sm={8} md={6} key={download.id}>
<>
<DownloadCard
download={download}
onStop={() => abort(download.id)}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/>
</>
</Grid>
))
}
</Grid>
)
}
export default DownloadsCardView

View File

@@ -0,0 +1,38 @@
import { Grid } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types'
import DownloadCard from './DownloadCard'
const DownloadsGridView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState)
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.Completed
? client.clear(r.id)
: client.kill(r.id)
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
{
downloads.map(download => (
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
<DownloadCard
download={download}
onStop={() => stop(download)}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/>
</Grid>
))
}
</Grid>
)
}
export default DownloadsGridView

View File

@@ -1,93 +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}>
<Table>
<TableHead hidden={downloads.length === 0}>
<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,216 @@
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 { forwardRef } from 'react'
import { TableComponents, TableVirtuoso } from 'react-virtuoso'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types'
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
const columns = [
{
width: 8,
label: 'Status',
dataKey: 'status',
numeric: false,
},
{
width: 500,
label: 'Title',
dataKey: 'title',
numeric: false,
},
{
width: 50,
label: 'Speed',
dataKey: 'speed',
numeric: true,
},
{
width: 150,
label: 'Progress',
dataKey: 'progress',
numeric: true,
},
{
width: 80,
label: 'Size',
dataKey: 'size',
numeric: true,
},
{
width: 100,
label: 'Added on',
dataKey: 'addedon',
numeric: true,
},
{
width: 80,
label: 'Actions',
dataKey: 'actions',
numeric: true,
},
] as const
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 VirtuosoTableComponents: TableComponents<RPCResult> = {
Scroller: forwardRef<HTMLDivElement>((props, ref) => (
<TableContainer {...props} ref={ref} />
)),
Table: (props) => (
<Table {...props} sx={{ borderCollapse: 'separate', tableLayout: 'fixed', mt: 2 }} size='small' />
),
TableHead,
TableRow: ({ item: _item, ...props }) => <TableRow {...props} />,
TableBody: forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableBody {...props} ref={ref} />
)),
}
function fixedHeaderContent() {
return (
<TableRow>
{columns.map((column) => (
<TableCell
key={column.dataKey}
variant="head"
align={column.numeric || false ? 'right' : 'left'}
style={{ width: column.width }}
>
{column.label}
</TableCell>
))}
</TableRow>
)
}
const DownloadsTableView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState)
const serverAddr = useRecoilValue(serverURL)
const { client } = useRPC()
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')}`)
}
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.Completed
? client.clear(r.id)
: client.kill(r.id)
function rowContent(_index: number, download: RPCResult) {
return (
<>
<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={() => stop(download)}
>
{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>
</>
)
}
return (
<Box style={{ height: downloads.length === 0 ? '0vh' : '80vh', width: '100%' }}>
<TableVirtuoso
hidden={downloads.length === 0}
data={downloads}
components={VirtuosoTableComponents}
fixedHeaderContent={fixedHeaderContent}
itemContent={rowContent}
/>
</Box>
)
}
export default DownloadsTableView

View File

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

View File

@@ -0,0 +1,57 @@
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
import { useI18n } from '../hooks/useI18n'
const ExtraDownloadOptions: React.FC = () => {
const { i18n } = useI18n()
const customTemplates = useRecoilValue(savedTemplatesState)
const [, setCustomArgs] = useRecoilState(customArgsState)
return (
<>
<Autocomplete
disablePortal
options={customTemplates.map(({ name, content }) => ({ label: name, content }))}
autoHighlight
defaultValue={
customTemplates
.filter(({ id, name }) => id === "0" || name === "default")
.map(({ name, content }) => ({ label: name, content }))
.at(0)
}
getOptionLabel={(option) => option.label}
onChange={(_, value) => {
setCustomArgs(value?.content!)
}}
renderOption={(props, option) => (
<Box
component="li"
{...props}
>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignContent: 'flex-start',
justifyContent: 'flex-start',
alignItems: 'flex-start',
width: '100%'
}}>
<Typography>
{option.label}
</Typography>
<Typography variant="subtitle2" color="primary">
{option.content}
</Typography>
</Box>
</Box>
)}
sx={{ width: '100%', mt: 2 }}
renderInput={(params) => <TextField {...params} label={i18n.t('savedTemplates')} />}
/>
</>
)
}
export default ExtraDownloadOptions

View File

@@ -0,0 +1,69 @@
import DownloadIcon from '@mui/icons-material/Download'
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 { totalDownloadSpeedState } from '../atoms/ui'
import { useI18n } from '../hooks/useI18n'
import { formatSpeedMiB } from '../utils'
import FreeSpaceIndicator from './FreeSpaceIndicator'
import VersionIndicator from './VersionIndicator'
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.1.0" 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,26 +1,21 @@
import StorageIcon from '@mui/icons-material/Storage'
import { useEffect, useState } from 'react'
import { formatGiB } from '../utils'
import { useRPC } from '../hooks/useRPC'
import { useRecoilValue } from 'recoil'
import { freeSpaceBytesState } from '../atoms/status'
import { formatSize } from '../utils'
const FreeSpaceIndicator = () => {
const [freeSpace, setFreeSpace] = useState(0)
const { client } = useRPC()
useEffect(() => {
client.freeSpace().then(r => setFreeSpace(r.result))
}, [client])
const freeSpace = useRecoilValue(freeSpaceBytesState)
return (
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 3
}}>
<StorageIcon />
<span>
&nbsp;{formatGiB(freeSpace)}&nbsp;
{formatSize(freeSpace)}
</span>
</div>
)

View File

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

View File

@@ -1,48 +1,61 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FolderZipIcon from '@mui/icons-material/FolderZip'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'
import {
SpeedDial,
SpeedDialAction,
SpeedDialIcon
} from '@mui/material'
import { useRecoilState } from 'recoil'
import { listViewState } from '../atoms/settings'
import { useRecoilState, useRecoilValue } from 'recoil'
import { listViewState, serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
type Props = {
onOpen: () => void
onDownloadOpen: () => void
onEditorOpen: () => void
}
const HomeSpeedDial: React.FC<Props> = ({ onOpen }) => {
const [, setListView] = useRecoilState(listViewState)
const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const serverAddr = useRecoilValue(serverURL)
const [listView, setListView] = useRecoilState(listViewState)
const { i18n } = useI18n()
const { client } = useRPC()
const abort = () => client.killAll()
return (
<SpeedDial
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<FormatListBulleted />}
tooltipTitle={`Table view`}
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
tooltipTitle={listView ? 'Card view' : 'Table view'}
onClick={() => setListView(state => !state)}
/>
<SpeedDialAction
icon={<FolderZipIcon />}
tooltipTitle={i18n.t('bulkDownload')}
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
/>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={abort}
onClick={() => client.killAll()}
/>
<SpeedDialAction
icon={<BuildCircleIcon />}
tooltipTitle={i18n.t('templatesEditor')}
onClick={onEditorOpen}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={`New download`}
onClick={onOpen}
tooltipTitle={i18n.t('newDownloadButton')}
onClick={onDownloadOpen}
/>
</SpeedDial>
)

View File

@@ -0,0 +1,83 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
const token = localStorage.getItem('token')
const LogTerminal: React.FC = () => {
const [logBuffer, setLogBuffer] = useState<string[]>([])
const [isConnecting, setIsConnecting] = useState(true)
const boxRef = useRef<HTMLDivElement>(null)
const serverAddr = useRecoilValue(serverURL)
const { i18n } = useI18n()
const eventSource = useMemo(
() => new EventSource(`${serverAddr}/log/sse?token=${token}`),
[serverAddr]
)
useEffect(() => {
eventSource.addEventListener('log', event => {
const msg: string[] = JSON.parse(event.data)
setLogBuffer(buff => [...buff, ...msg].slice(-500))
boxRef.current?.scrollTo(0, boxRef.current.scrollHeight)
})
// TODO: in dev mode it breaks sse
return () => eventSource.close()
}, [eventSource])
useEffect(() => {
eventSource.onopen = () => setIsConnecting(false)
}, [eventSource])
const logEntryStyle = (data: string) => {
const sx = {}
if (data.includes("level=ERROR")) {
return { ...sx, color: 'red' }
}
if (data.includes("level=WARN")) {
return { ...sx, color: 'orange' }
}
return sx
}
return (
<div
ref={boxRef}
style={{
fontFamily: 'Roboto Mono',
height: '70.5vh',
overflowY: 'auto',
overflowX: 'auto',
fontSize: '13.5px',
fontWeight: '600',
backgroundColor: 'black',
color: 'white',
padding: '0.5rem',
borderRadius: '0.25rem'
}}
>
{isConnecting ? <div>{'Connecting...'}</div> : <div>{'Connected!'}</div>}
{logBuffer.length === 0 && <div>{i18n.t('awaitingLogs')}</div>}
{logBuffer.map((log, idx) => (
<div key={idx} style={logEntryStyle(log)}>
{log}
</div>
))}
</div>
)
}
export default LogTerminal

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -7,6 +7,8 @@ import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css'
import '@fontsource/roboto-mono'
const root = createRoot(document.getElementById('root')!)
root.render(

View File

@@ -1,61 +0,0 @@
export class CliArguments {
private _extractAudio: boolean
private _noMTime: boolean
private _proxy: string
constructor(extractAudio = false, noMTime = true) {
this._extractAudio = extractAudio
this._noMTime = noMTime
this._proxy = ""
}
public get extractAudio(): boolean {
return this._extractAudio
}
public toggleExtractAudio() {
this._extractAudio = !this._extractAudio
return this
}
public disableExtractAudio() {
this._extractAudio = false
return this
}
public get noMTime(): boolean {
return this._noMTime
}
public toggleNoMTime() {
this._noMTime = !this._noMTime
return this
}
public toString(): string {
let args = ''
if (this._extractAudio) {
args += '-x '
}
if (this._noMTime) {
args += '--no-mtime '
}
return args.trim()
}
public fromString(str: string): CliArguments {
if (str) {
if (str.includes('-x')) {
this._extractAudio = true
}
if (str.includes('--no-mtime')) {
this._noMTime = true
}
}
return this
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { CircularProgress } from '@mui/material'
import { Suspense, lazy } from 'react'
import { createHashRouter } from 'react-router-dom'
import Layout from './Layout'
import Terminal from './views/Terminal'
const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login'))
@@ -36,6 +37,14 @@ export const router = createHashRouter([
</Suspense >
)
},
{
path: '/log',
element: (
<Suspense fallback={<CircularProgress />}>
<Terminal />
</Suspense >
)
},
{
path: '/archive',
element: (
@@ -57,6 +66,14 @@ export const router = createHashRouter([
</Suspense >
)
},
{
path: '/error',
element: (
<Suspense fallback={<CircularProgress />}>
<ErrorBoundary />
</Suspense >
)
},
]
},
])

View File

@@ -16,11 +16,11 @@ export type RPCRequest = {
id?: string
}
export type RPCResponse<T> = {
export type RPCResponse<T> = Readonly<{
result: T
error: number | null
id?: string
}
}>
type DownloadInfo = {
url: string
@@ -34,25 +34,35 @@ type DownloadInfo = {
created_at: string
}
export enum ProcessStatus {
Pending = 0,
Downloading,
Completed,
Errored,
}
type DownloadProgress = {
speed: number
eta: number
percentage: string
process_status: number
process_status: ProcessStatus
}
export type RPCResult = {
export type RPCResult = Readonly<{
id: string
progress: DownloadProgress
info: DownloadInfo
}
output: {
savedFilePath: string
}
}>
export type RPCParams = {
URL: string
Params?: string
}
export interface DLMetadata {
export type DLMetadata = {
formats: Array<DLFormat>
best: DLFormat
thumbnail: string
@@ -73,13 +83,17 @@ export type DirectoryEntry = {
name: string
path: string
size: number
shaSum: string
modTime: string
isVideo: boolean
isDirectory: boolean
}
export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
export type DeleteRequest = Pick<DirectoryEntry, 'path'>
export type PlayRequest = Pick<DirectoryEntry, 'path'>
export type PlayRequest = DeleteRequest
export type CustomTemplate = {
id: string
name: string
content: string
}

View File

@@ -1,11 +1,7 @@
import { pipe } from 'fp-ts/lib/function'
import type { RPCResponse } from "./types"
import { ProcessStatus } from './types'
/**
* Validate an ip v4 via regex
* @param {string} ipAddr
* @returns ip validity test
*/
export function validateIP(ipAddr: string): boolean {
let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm
return ipRegex.test(ipAddr)
@@ -20,17 +16,10 @@ export function validateDomain(url: string): boolean {
return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug)
}
export function isValidURL(url: string): boolean {
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
return urlRegex.test(url)
}
export function ellipsis(str: string, lim: number): string {
if (str) {
return str.length > lim ? `${str.substring(0, lim)}...` : str
}
return ''
}
export const ellipsis = (str: string, lim: number) =>
str.length > lim
? `${str.substring(0, lim)}...`
: str
export function toFormatArgs(codes: string[]): string {
if (codes.length > 1) {
@@ -42,14 +31,21 @@ export function toFormatArgs(codes: string[]): string {
return ''
}
export const formatGiB = (bytes: number) =>
`${(bytes / 1_000_000_000).toFixed(0)}GiB`
export function formatSize(bytes: number): string {
const threshold = 1024
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
export const roundMiB = (bytes: number) =>
`${(bytes / 1_000_000).toFixed(2)} MiB`
let i = 0
while (bytes >= threshold) {
bytes /= threshold
i = i + 1
}
return `${bytes.toFixed(i == 0 ? 0 : 2)} ${units.at(i)}`
}
export const formatSpeedMiB = (val: number) =>
`${roundMiB(val)}/s`
`${(val / 1_048_576).toFixed(2)} MiB/s`
export const datetimeCompareFunc = (a: string, b: string) =>
new Date(a).getTime() - new Date(b).getTime()
@@ -58,15 +54,15 @@ export function isRPCResponse(object: any): object is RPCResponse<any> {
return 'result' in object && 'id' in object
}
export function mapProcessStatus(status: number) {
export function mapProcessStatus(status: ProcessStatus) {
switch (status) {
case 0:
case ProcessStatus.Pending:
return 'Pending'
case 1:
case ProcessStatus.Downloading:
return 'Downloading'
case 2:
case ProcessStatus.Completed:
return 'Completed'
case 3:
case ProcessStatus.Errored:
return 'Error'
default:
return 'Pending'

View File

@@ -14,6 +14,8 @@ import {
ListItemButton,
ListItemIcon,
ListItemText,
MenuItem,
MenuList,
Paper,
SpeedDial,
SpeedDialAction,
@@ -26,6 +28,7 @@ import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import VideoFileIcon from '@mui/icons-material/VideoFile'
import DownloadIcon from '@mui/icons-material/Download'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
import { useEffect, useMemo, useState, useTransition } from 'react'
@@ -37,10 +40,14 @@ import { useObservable } from '../hooks/observable'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient'
import { DeleteRequest, DirectoryEntry } from '../types'
import { base64URLEncode, roundMiB } from '../utils'
import { DirectoryEntry } from '../types'
import { base64URLEncode, formatSize } from '../utils'
export default function Downloaded() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
const [showMenu, setShowMenu] = useState(false)
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
const serverAddr = useRecoilValue(serverURL)
const navigate = useNavigate()
@@ -66,10 +73,10 @@ export default function Downloaded() {
),
matchW(
(e) => {
pushMessage(e)
pushMessage(e, 'error')
navigate('/login')
},
(d) => files$.next(d),
(d) => files$.next(d ?? []),
)
)()
@@ -87,24 +94,31 @@ export default function Downloaded() {
? ['.', ..._upperLevel].join('/')
: _upperLevel.join('/')
fetch(`${serverAddr}/archive/downloaded`, {
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subdir: relpath })
})
.then(res => res.json())
.then(data => {
files$.next(sub
pipe(
task,
matchW(
(l) => pushMessage(l, 'error'),
(r) => files$.next(sub
? [{
name: '..',
isDirectory: true,
isVideo: false,
modTime: '',
name: '..',
path: upperLevel,
}, ...data]
: data
size: 0,
}, ...r.filter(f => f.name !== '')]
: r.filter(f => f.name !== '')
)
})
)
)()
}
const selectable$ = useMemo(() => files$.pipe(
@@ -124,19 +138,23 @@ export default function Downloaded() {
: selected$.next([...selected$.value, name])
}
const deleteFile = (entry: DirectoryEntry) => pipe(
ffetch(`${serverAddr}/archive/delete`, {
method: 'POST',
body: JSON.stringify({
path: entry.path,
})
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => fetcher()
)
)()
const deleteSelected = () => {
Promise.all(selectable
.filter(entry => entry.selected)
.map(entry => fetch(`${serverAddr}/archive/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: entry.path,
shaSum: entry.shaSum,
} as DeleteRequest)
}))
.map(deleteFile)
).then(fetcher)
}
@@ -147,7 +165,13 @@ export default function Downloaded() {
const onFileClick = (path: string) => startTransition(() => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}`)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
})
const downloadFile = (path: string) => startTransition(() => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
})
const onFolderClick = (path: string) => startTransition(() => {
@@ -155,18 +179,42 @@ export default function Downloaded() {
})
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Container
maxWidth="xl"
sx={{ mt: 4, mb: 4, height: '100%' }}
onClick={() => setShowMenu(false)}
>
<IconMenu
posX={menuPos.x}
posY={menuPos.y}
hide={!showMenu}
onDownload={() => {
if (currentFile) {
downloadFile(currentFile?.path)
setCurrentFile(undefined)
}
}}
onDelete={() => {
if (currentFile) {
deleteFile(currentFile)
setCurrentFile(undefined)
}
}}
/>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!(files$.observed) || isPending}
>
<CircularProgress color="primary" />
</Backdrop>
<Paper sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
onClick={() => setShowMenu(false)}
>
<Typography py={1} variant="h5" color="primary">
{i18n.t('archiveTitle')}
</Typography>
@@ -174,6 +222,12 @@ export default function Downloaded() {
{selectable.length === 0 && 'No files found'}
{selectable.map((file, idx) => (
<ListItem
onContextMenu={(e) => {
e.preventDefault()
setCurrentFile(file)
setMenuPos({ x: e.clientX, y: e.clientY })
setShowMenu(true)
}}
key={idx}
secondaryAction={
<div>
@@ -181,14 +235,16 @@ export default function Downloaded() {
variant="caption"
component="span"
>
{roundMiB(file.size)}
{formatSize(file.size)}
</Typography>
}
{!file.isDirectory && <Checkbox
edge="end"
checked={file.selected}
onChange={() => addSelected(file.name)}
/>}
{!file.isDirectory && <>
<Checkbox
edge="end"
checked={file.selected}
onChange={() => addSelected(file.name)}
/>
</>}
</div>
}
disablePadding
@@ -216,8 +272,8 @@ export default function Downloaded() {
</List>
</Paper>
<SpeedDial
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
ariaLabel='archive actions'
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
@@ -249,11 +305,15 @@ export default function Downloaded() {
</ul>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
<Button onClick={() => {
deleteSelected()
setOpenDialog(false)
}} autoFocus
<Button onClick={() => setOpenDialog(false)}>
Cancel
</Button>
<Button
onClick={() => {
deleteSelected()
setOpenDialog(false)
}}
autoFocus
>
Ok
</Button>
@@ -261,4 +321,43 @@ export default function Downloaded() {
</Dialog>
</Container>
)
}
const IconMenu: React.FC<{
posX: number
posY: number
hide: boolean
onDownload: () => void
onDelete: () => void
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
return (
<Paper sx={{
width: 320,
maxWidth: '100%',
position: 'absolute',
top: posY,
left: posX,
display: hide ? 'none' : 'block',
zIndex: (theme) => theme.zIndex.drawer + 1,
}}>
<MenuList>
<MenuItem onClick={onDownload}>
<ListItemIcon>
<DownloadIcon fontSize="small" />
</ListItemIcon>
<ListItemText>
Download
</ListItemText>
</MenuItem>
<MenuItem onClick={onDelete}>
<ListItemIcon>
<DeleteForeverIcon fontSize="small" />
</ListItemIcon>
<ListItemText>
Delete
</ListItemText>
</MenuItem>
</MenuList>
</Paper>
)
}

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import {
Container,
FormControl,
FormControlLabel,
FormGroup,
Grid,
InputAdornment,
InputLabel,
@@ -12,6 +11,7 @@ import {
Paper,
Select,
SelectChangeEvent,
Slider,
Stack,
Switch,
TextField,
@@ -27,6 +27,7 @@ import {
map,
takeWhile
} from 'rxjs'
import { rpcPollingTimeState } from '../atoms/rpc'
import {
Language,
Theme,
@@ -36,9 +37,9 @@ import {
formatSelectionState,
languageState,
languages,
latestCliArgumentsState,
pathOverridingState,
servedFromReverseProxyState,
servedFromReverseProxySubDirState,
serverAddressState,
serverPortState,
themeState
@@ -47,21 +48,25 @@ import CookiesTextField from '../components/CookiesTextField'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser'
import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() {
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
const [baseURL, setBaseURL] = useRecoilState(servedFromReverseProxySubDirState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState)
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
const [serverPort, setServerPort] = useRecoilState(serverPortState)
const [pollingTime, setPollingTime] = useRecoilState(rpcPollingTimeState)
const [language, setLanguage] = useRecoilState(languageState)
const [appTitle, setApptitle] = useRecoilState(appTitleState)
const [cliArgs, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [theme, setTheme] = useRecoilState(themeState)
const [invalidIP, setInvalidIP] = useState(false)
@@ -71,11 +76,20 @@ export default function Settings() {
const { pushMessage } = useToast()
const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), [])
const baseURL$ = useMemo(() => new Subject<string>(), [])
const serverAddr$ = 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(() => {
const sub = serverAddr$
.pipe(
@@ -130,196 +144,223 @@ export default function Settings() {
* Updates yt-dlp binary via RPC
*/
const updateBinary = () => {
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated')))
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
}
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={12} lg={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
minHeight: 240,
}}
>
<Typography pb={3} variant="h5" color="primary">
{i18n.t('settingsAnchor')}
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Paper
sx={{
p: 2.5,
display: 'flex',
flexDirection: 'column',
minHeight: 240,
}}
>
<Typography pb={2} variant="h6" color="primary">
{i18n.t('settingsAnchor')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={11}>
<TextField
fullWidth
label={i18n.t('serverAddressTitle')}
defaultValue={serverAddr}
error={invalidIP}
onChange={(e) => serverAddr$.next(e.currentTarget.value)}
InputProps={{
startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
}}
/>
</Grid>
<Grid item xs={12} md={1}>
<TextField
disabled={reverseProxy}
fullWidth
label={i18n.t('serverPortTitle')}
defaultValue={serverPort}
onChange={(e) => serverPort$.next(e.currentTarget.value)}
error={isNaN(Number(serverPort)) || Number(serverPort) > 65535}
/>
</Grid>
<Grid item xs={12} md={12}>
<TextField
disabled={reverseProxy}
fullWidth
label={i18n.t('appTitle')}
defaultValue={appTitle}
onChange={(e) => setApptitle(e.currentTarget.value)}
error={appTitle === ''}
/>
</Grid>
<Grid item xs={12} md={12}>
<Typography>
{i18n.t('rpcPollingTimeTitle')}
</Typography>
<FormGroup>
<Grid container spacing={2}>
<Grid item xs={12} md={11}>
<TextField
fullWidth
label={i18n.t('serverAddressTitle')}
defaultValue={serverAddr}
error={invalidIP}
onChange={(e) => serverAddr$.next(e.currentTarget.value)}
InputProps={{
startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
}}
/>
</Grid>
<Grid item xs={12} md={1}>
<TextField
disabled={reverseProxy}
fullWidth
label={i18n.t('serverPortTitle')}
defaultValue={serverPort}
onChange={(e) => serverPort$.next(e.currentTarget.value)}
error={isNaN(Number(serverPort)) || Number(serverPort) > 65535}
/>
</Grid>
<Grid item xs={12} md={12}>
<TextField
disabled={reverseProxy}
fullWidth
label={i18n.t('appTitle')}
defaultValue={appTitle}
onChange={(e) => setApptitle(e.currentTarget.value)}
error={appTitle === ''}
/>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Checkbox
defaultChecked={reverseProxy}
onChange={() => setReverseProxy(state => !state)}
/>
}
label={i18n.t('servedFromReverseProxyCheckbox')}
sx={{ mb: 2 }}
/>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
<Select
defaultValue={language}
label={i18n.t('languageSelect')}
onChange={handleLanguageChange}
>
{languages.map(l => (
<MenuItem value={l} key={l}>
{capitalize(l)}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{i18n.t('themeSelect')}</InputLabel>
<Select
defaultValue={theme}
label={i18n.t('themeSelect')}
onChange={handleThemeChange}
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
<MenuItem value="system">System</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<FormControlLabel
control={
<Switch
defaultChecked={argsBuilder.noMTime}
onChange={() => setCliArgs(argsBuilder.toggleNoMTime().toString())}
/>
<Typography variant='caption' sx={{ mb: 0.5 }}>
{i18n.t('rpcPollingTimeDescription')}
</Typography>
<Slider
aria-label="rpc polling time"
defaultValue={pollingTime}
max={2000}
getAriaValueText={(v: number) => `${v} ms`}
step={null}
valueLabelDisplay="off"
marks={[
{ value: 100, label: '100 ms' },
{ value: 250, label: '250 ms' },
{ value: 500, label: '500 ms' },
{ value: 750, label: '750 ms' },
{ value: 1000, label: '1000 ms' },
{ value: 2000, label: '2000 ms' },
]}
onChange={(_, value) => typeof value === 'number'
? setPollingTime(value)
: setPollingTime(1000)
}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="h6" color="primary" sx={{ mb: 0.5 }}>
Reverse Proxy
</Typography>
<FormControlLabel
control={
<Checkbox
defaultChecked={reverseProxy}
onChange={() => setReverseProxy(state => !state)}
/>
}
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)
}
label={i18n.t('noMTimeCheckbox')}
sx={{ mt: 3 }}
/>
<FormControlLabel
control={
<Switch
defaultChecked={argsBuilder.extractAudio}
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
disabled={formatSelection}
/>
if (value.endsWith('/')) {
value = value.substring(0, value.length - 1)
}
label={i18n.t('extractAudioCheckbox')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={formatSelection}
onChange={() => {
setCliArgs(argsBuilder.disableExtractAudio().toString())
setFormatSelection(!formatSelection)
}}
/>
}
label={i18n.t('formatSelectionEnabler')}
/>
<Grid>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
{i18n.t('overridesAnchor')}
</Typography>
<Stack direction="column">
<FormControlLabel
control={
<Switch
defaultChecked={!!pathOverriding}
onChange={() => {
setPathOverriding(state => !state)
}}
/>
}
label={i18n.t('pathOverrideOption')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={fileRenaming}
onChange={() => {
setFileRenaming(state => !state)
}}
/>
}
label={i18n.t('filenameOverrideOption')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={enableArgs}
onChange={() => {
setEnableArgs(state => !state)
}}
/>
}
label={i18n.t('customArgs')}
/>
</Stack>
</Grid>
<Grid sx={{ mr: 1, mt: 3 }}>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
Cookies
</Typography>
<CookiesTextField />
</Grid>
<Grid>
<Stack direction="row">
<Button
sx={{ mr: 1, mt: 3 }}
variant="contained"
onClick={() => updateBinary()}
>
{i18n.t('updateBinButton')}
</Button>
</Stack>
</Grid>
</FormGroup>
</Paper>
baseURL$.next(value)
}}
sx={{ mb: 2 }}
/>
</Grid>
</Grid>
</Grid>
<Typography variant="h6" color="primary" sx={{ mt: 0.5, mb: 2 }}>
Appearance
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
<Select
defaultValue={language}
label={i18n.t('languageSelect')}
onChange={handleLanguageChange}
>
{languages.toSorted((a, b) => a.localeCompare(b)).map(l => (
<MenuItem value={l} key={l}>
{capitalize(l)}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{i18n.t('themeSelect')}</InputLabel>
<Select
defaultValue={theme}
label={i18n.t('themeSelect')}
onChange={handleThemeChange}
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
<MenuItem value="system">System</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
General download settings
</Typography>
<FormControlLabel
control={
<Switch
defaultChecked={formatSelection}
onChange={() => {
setFormatSelection(!formatSelection)
}}
/>
}
label={i18n.t('formatSelectionEnabler')}
/>
<Grid>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
{i18n.t('overridesAnchor')}
</Typography>
<Stack direction="column">
<FormControlLabel
control={
<Switch
defaultChecked={!!pathOverriding}
onChange={() => {
setPathOverriding(state => !state)
}}
/>
}
label={i18n.t('pathOverrideOption')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={fileRenaming}
onChange={() => {
setFileRenaming(state => !state)
}}
/>
}
label={i18n.t('filenameOverrideOption')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={enableArgs}
onChange={() => {
setEnableArgs(state => !state)
}}
/>
}
label={i18n.t('customArgs')}
/>
</Stack>
</Grid>
<Grid sx={{ mr: 1, mt: 2 }}>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
Cookies
</Typography>
<CookiesTextField />
</Grid>
<Grid>
<Stack direction="row">
<Button
sx={{ mr: 1, mt: 3 }}
variant="contained"
onClick={() => updateBinary()}
>
{i18n.t('updateBinButton')}
</Button>
</Stack>
</Grid>
</Paper>
</Container>
)
}

View File

@@ -0,0 +1,26 @@
import { Container, Paper, Typography } from '@mui/material'
import LogTerminal from '../components/LogTerminal'
import { useI18n } from '../hooks/useI18n'
const Terminal: React.FC = () => {
const { i18n } = useI18n()
return (
<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 >
)
}
export default Terminal

View File

@@ -1,10 +1,12 @@
import react from '@vitejs/plugin-react-swc'
import million from 'million/compiler'
import ViteYaml from '@modyfi/vite-plugin-yaml'
import { defineConfig } from 'vite'
export default defineConfig(() => {
return {
plugins: [
million.vite({ auto: true }),
react(),
ViteYaml(),
],

3187
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

40
go.mod
View File

@@ -1,15 +1,39 @@
module github.com/marcopeocchi/yt-dlp-web-ui
go 1.20
go 1.22
require (
github.com/go-chi/chi/v5 v5.0.10
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/goccy/go-json v0.10.2
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.3.1
github.com/gorilla/websocket v1.5.0
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
golang.org/x/sys v0.13.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/reactivex/rxgo/v2 v2.5.0
golang.org/x/sync v0.6.0
golang.org/x/sys v0.18.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.5
)
require (
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/teivah/onecontext v1.3.0 // indirect
golang.org/x/net v0.22.0 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.47.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

123
go.sum
View File

@@ -1,22 +1,111 @@
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
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.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.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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
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/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
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/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
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/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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-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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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 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/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
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/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
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/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.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
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=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.19.5 h1:QlsZyQ1zf78DGeqnQ9ILi9hXyMdoC5e1qoGNUyBjHQw=
modernc.org/cc/v4 v4.19.5/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.13.0 h1:99E8QHRoPrXN8VpS0zgAgJ5nSjpXrPKpsJIMvGL/2Oc=
modernc.org/ccgo/v4 v4.13.0/go.mod h1:Td6RI9W9G2ZpKHaJ7UeGEiB2aIpoDqLBnm4wtkbJTbQ=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
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/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
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/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

67
main.go
View File

@@ -14,12 +14,14 @@ import (
)
var (
port int
queueSize int
configFile string
downloadPath string
downloaderPath string
sessionFilePath string
host string
port int
queueSize int
configFile string
downloadPath string
downloaderPath string
sessionFilePath string
localDatabasePath string
requireAuth bool
username string
@@ -28,20 +30,30 @@ var (
userFromEnv = os.Getenv("USERNAME")
passFromEnv = os.Getenv("PASSWORD")
logFile string
enableFileLogging bool
//go:embed frontend/dist/index.html
//go:embed frontend/dist/assets/*
frontend embed.FS
//go:embed openapi/*
swagger embed.FS
)
func init() {
flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at")
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
flag.IntVar(&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(&downloadPath, "out", ".", "Where files will be saved")
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.StringVar(&sessionFilePath, "session", ".", "session file 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.StringVar(&username, "user", userFromEnv, "Username required for auth")
@@ -52,27 +64,40 @@ func init() {
func main() {
frontend, err := fs.Sub(frontend, "frontend/dist")
if err != nil {
log.Fatalln(err)
}
c := config.Instance()
c.SetPort(port)
c.QueueSize(queueSize)
c.DownloadPath(downloadPath)
c.DownloaderPath(downloaderPath)
c.SessionFilePath(sessionFilePath)
c.Host = host
c.Port = port
c.QueueSize = queueSize
c.DownloadPath = downloadPath
c.DownloaderPath = downloaderPath
c.SessionFilePath = sessionFilePath
c.RequireAuth(requireAuth)
c.Username(username)
c.Password(password)
c.RequireAuth = requireAuth
c.Username = username
c.Password = password
// if config file is found it will be merged with the current config struct
if _, err := c.LoadFromFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, "no config file found")
// limit concurrent downloads for systems with 2 or less logical cores
if runtime.NumCPU() <= 2 {
c.QueueSize = 1
}
server.RunBlocking(port, frontend)
// if config file is found it will be merged with the current config struct
if err := c.LoadFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, err)
}
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);
}

51
server/config/config.go Normal file
View File

@@ -0,0 +1,51 @@
package config
import (
"os"
"sync"
"gopkg.in/yaml.v3"
)
type Config struct {
CurrentLogFile string
LogPath string `yaml:"log_path"`
BaseURL string `yaml:"base_url"`
Host string `yaml:"host"`
Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"`
RequireAuth bool `yaml:"require_auth"`
Username string `yaml:"username"`
Password string `yaml:"password"`
QueueSize int `yaml:"queue_size"`
SessionFilePath string `yaml:"session_file_path"`
}
var (
instance *Config
instanceOnce sync.Once
)
func Instance() *Config {
if instance == nil {
instanceOnce.Do(func() {
instance = &Config{}
})
}
return instance
}
// Initialises the Config struct given its config file
func (c *Config) LoadFile(filename string) error {
fd, err := os.Open(filename)
if err != nil {
return err
}
if err := yaml.NewDecoder(fd).Decode(c); err != nil {
return err
}
return nil
}

View File

@@ -1,87 +0,0 @@
package config
import (
"os"
"sync"
"gopkg.in/yaml.v3"
)
var lock sync.Mutex
type serverConfig struct {
Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"`
RequireAuth bool `yaml:"require_auth"`
Username string `yaml:"username"`
Password string `yaml:"password"`
QueueSize int `yaml:"queue_size"`
SessionFilePath string `yaml:"session_file_path"`
}
type config struct {
cfg serverConfig
}
func (c *config) LoadFromFile(filename string) (serverConfig, error) {
fd, err := os.Open(filename)
if err != nil {
return serverConfig{}, err
}
if err := yaml.NewDecoder(fd).Decode(&c.cfg); err != nil {
return serverConfig{}, err
}
return c.cfg, nil
}
func (c *config) GetConfig() serverConfig {
return c.cfg
}
func (c *config) SetPort(port int) {
c.cfg.Port = port
}
func (c *config) DownloadPath(path string) {
c.cfg.DownloadPath = path
}
func (c *config) DownloaderPath(path string) {
c.cfg.DownloaderPath = path
}
func (c *config) RequireAuth(value bool) {
c.cfg.RequireAuth = value
}
func (c *config) Username(username string) {
c.cfg.Username = username
}
func (c *config) Password(password string) {
c.cfg.Password = password
}
func (c *config) QueueSize(size int) {
c.cfg.QueueSize = size
}
func (c *config) SessionFilePath(path string) {
c.cfg.SessionFilePath = path
}
var instance *config
func Instance() *config {
if instance == nil {
lock.Lock()
defer lock.Unlock()
if instance == nil {
instance = &config{serverConfig{}}
}
}
return instance
}

39
server/dbutil/migrate.go Normal file
View File

@@ -0,0 +1,39 @@
package dbutil
import (
"context"
"database/sql"
)
// Run the table migration
func AutoMigrate(ctx context.Context, db *sql.DB) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = db.ExecContext(
ctx,
`CREATE TABLE IF NOT EXISTS templates (
id CHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content TEXT NOT NULL
)`,
)
if err != nil {
return err
}
db.ExecContext(
ctx,
`INSERT INTO templates (id, name, content) VALUES
($1, $2, $3),
($4, $5, $6);`,
"0", "default", "--no-mtime",
"1", "audio only", "-x",
)
return err
}

View File

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

@@ -1,36 +1,37 @@
package handlers
import (
"encoding/json"
"net/http"
"os"
"time"
"github.com/goccy/go-json"
"github.com/golang-jwt/jwt/v5"
"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 {
Username string `json:"username"`
Password string `json:"password"`
}
func Login(w http.ResponseWriter, r *http.Request) {
req := new(LoginRequest)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var (
username = config.Instance().GetConfig().Username
password = config.Instance().GetConfig().Password
username = config.Instance().Username
password = config.Instance().Password
)
if username != req.Username || password != req.Password {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, "invalid username or password", http.StatusBadRequest)
return
}
@@ -47,21 +48,15 @@ func Login(w http.ResponseWriter, r *http.Request) {
return
}
cookie := &http.Cookie{
Name: utils.TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
Expires: expiresAt, // 30 days
Value: tokenString,
Path: "/",
if err := json.NewEncoder(w).Encode(tokenString); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, cookie)
}
func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: utils.TOKEN_COOKIE_NAME,
Name: TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
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,12 +2,27 @@ package internal
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
type DownloadProgress struct {
Status int `json:"process_status"`
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
ETA int `json:"eta"`
ETA float32 `json:"eta"`
}
// Used to deser the yt-dlp -J output
@@ -21,6 +36,7 @@ type DownloadInfo struct {
ACodec string `json:"acodec"`
Extension string `json:"ext"`
OriginalURL string `json:"original_url"`
FileName string `json:"filename"`
CreatedAt time.Time `json:"created_at"`
}
@@ -50,6 +66,8 @@ type ProcessResponse struct {
Id string `json:"id"`
Progress DownloadProgress `json:"progress"`
Info DownloadInfo `json:"info"`
Output DownloadOutput `json:"output"`
Params []string `json:"params"`
}
// struct representing the current status of the memoryDB
@@ -76,3 +94,10 @@ type DownloadRequest struct {
type SetCookiesRequest struct {
Cookies string `json:"cookies"`
}
// represents a user defined collection of yt-dlp arguments
type CustomTemplate struct {
Id string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
}

View File

@@ -3,14 +3,12 @@ package internal
import (
"encoding/gob"
"errors"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
@@ -25,109 +23,84 @@ func (m *MemoryDB) Get(id string) (*Process, error) {
if !ok {
return nil, errors.New("no process found for the given key")
}
return entry.(*Process), nil
}
// Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string {
id := uuid.Must(uuid.NewRandom()).String()
id := uuid.NewString()
m.table.Store(id, process)
process.Id = 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
func (m *MemoryDB) Delete(id string) {
m.table.Delete(id)
}
func (m *MemoryDB) Keys() *[]string {
running := []string{}
var running []string
m.table.Range(func(key, value any) bool {
running = append(running, key.(string))
return true
})
return &running
}
// Returns a slice of all currently stored processes progess
func (m *MemoryDB) All() *[]ProcessResponse {
running := []ProcessResponse{}
m.table.Range(func(key, value any) bool {
running = append(running, ProcessResponse{
Id: key.(string),
Info: value.(*Process).Info,
Progress: value.(*Process).Progress,
Output: value.(*Process).Output,
Params: value.(*Process).Params,
})
return true
})
return &running
}
// WIP: Persist the database in a single file named "session.dat"
func (m *MemoryDB) Persist() {
// Persist the database in a single file named "session.dat"
func (m *MemoryDB) Persist() error {
running := m.All()
sessionFile := filepath.Join(
config.Instance().GetConfig().SessionFilePath,
"session.dat",
)
sf := filepath.Join(config.Instance().SessionFilePath, "session.dat")
fd, err := os.Create(sessionFile)
fd, err := os.Create(sf)
if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset)
return errors.Join(errors.New("failed to persist session"), err)
}
session := Session{
Processes: *running,
session := Session{Processes: *running}
if err := gob.NewEncoder(fd).Encode(session); err != nil {
return errors.Join(errors.New("failed to persist session"), err)
}
err = gob.NewEncoder(fd).Encode(session)
if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset)
}
log.Println(cli.BgBlue, "Successfully serialized session", cli.Reset)
return nil
}
// WIP: Restore a persisted state
func (m *MemoryDB) Restore() {
// Restore a persisted state
func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) {
fd, err := os.Open("session.dat")
if err != nil {
return
}
session := Session{}
var session Session
err = gob.NewDecoder(fd).Decode(&session)
if err != nil {
if err := gob.NewDecoder(fd).Decode(&session); err != nil {
return
}
@@ -137,14 +110,15 @@ func (m *MemoryDB) Restore() {
Url: proc.Info.URL,
Info: proc.Info,
Progress: proc.Progress,
Output: proc.Output,
Params: proc.Params,
Logger: logger,
}
m.table.Store(proc.Id, restored)
if restored.Progress.Percentage != "-1" {
go restored.Start()
if restored.Progress.Status != StatusCompleted {
mq.Publish(restored)
}
}
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)
}

View File

@@ -1,65 +1,112 @@
package internal
import (
"log"
"context"
"errors"
"log/slog"
evbus "github.com/asaskevich/EventBus"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"golang.org/x/sync/semaphore"
)
const queueName = "process:pending"
type MessageQueue struct {
producerCh chan *Process
consumerCh chan struct{}
concurrency int
eventBus evbus.Bus
logger *slog.Logger
}
// Creates a new message queue.
// 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.
func NewMessageQueue() *MessageQueue {
size := config.Instance().GetConfig().QueueSize
func NewMessageQueue(l *slog.Logger) (*MessageQueue, error) {
qs := config.Instance().QueueSize
if size <= 0 {
log.Fatalln("invalid queue size")
if qs <= 0 {
return nil, errors.New("invalid queue size")
}
return &MessageQueue{
producerCh: make(chan *Process, size),
consumerCh: make(chan struct{}, size),
}
concurrency: qs,
eventBus: evbus.New(),
logger: l,
}, nil
}
// Publish a message to the queue and set the task to a peding state.
func (m *MessageQueue) Publish(p *Process) {
// needs to have an id set before
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.
// ENSURE P IS PART OF A PLAYLIST
// Needs a further review
func (m *MessageQueue) PublishPlaylistEntry(p *Process) {
m.producerCh <- p
func (m *MessageQueue) SetupConsumers() {
go m.downloadConsumer()
go m.metadataSubscriber()
}
// Setup the consumer listener which subscribes to the changes to the producer
// channel and triggers the "download" action.
func (m *MessageQueue) Subscriber() {
for msg := range m.producerCh {
m.consumerCh <- struct{}{}
go func(p *Process) {
func (m *MessageQueue) downloadConsumer() {
sem := semaphore.NewWeighted(int64(m.concurrency))
m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.Background(), 1)
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()),
)
if p.Progress.Status != StatusCompleted {
p.Start()
<-m.consumerCh
}(msg)
}
}
m.logger.Info("started process",
slog.String("bus", queueName),
slog.String("id", p.getShortId()),
)
}, false)
}
// Empties the message queue
func (m *MessageQueue) Empty() {
for range m.producerCh {
<-m.producerCh
}
for range m.consumerCh {
<-m.consumerCh
}
// Setup the metadata consumer listener which subscribes to the changes to the
// producer channel and adds metadata to each download.
func (m *MessageQueue) metadataSubscriber() {
// How many concurrent metadata fetcher jobs are spawned
// Since there's ongoing downloads, 1 job at time seems a good compromise
sem := semaphore.NewWeighted(1)
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 p.Progress.Status == StatusCompleted {
m.logger.Warn("proccess has an illegal state",
slog.String("id", p.getShortId()),
slog.Int("status", p.Progress.Status),
)
return
}
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

@@ -1,26 +1,28 @@
package internal
import (
"encoding/json"
"errors"
"log"
"log/slog"
"os/exec"
"slices"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
type metadata struct {
Entries []DownloadInfo `json:"entries"`
Count int `json:"playlist_count"`
Type string `json:"_type"`
Entries []DownloadInfo `json:"entries"`
Count int `json:"playlist_count"`
PlaylistTitle string `json:"title"`
Type string `json:"_type"`
}
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger *slog.Logger) error {
var (
downloader = config.Instance().GetConfig().DownloaderPath
cmd = exec.Command(downloader, req.URL, "-J")
downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J")
)
stdout, err := cmd.StdoutPipe()
@@ -28,60 +30,75 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
return err
}
m := metadata{}
var m metadata
err = cmd.Start()
if err != nil {
if err := cmd.Start(); err != nil {
return err
}
log.Println(cli.BgRed, "Decoding metadata", cli.Reset, req.URL)
logger.Info("decoding playlist metadata", slog.String("url", req.URL))
err = json.NewDecoder(stdout).Decode(&m)
if err != nil {
if err := json.NewDecoder(stdout).Decode(&m); err != nil {
return err
}
log.Println(cli.BgGreen, "Decoded metadata", cli.Reset, req.URL)
if err := cmd.Wait(); err != nil {
return err
}
logger.Info("decoded playlist metadata", slog.String("url", req.URL))
if m.Type == "" {
cmd.Wait()
return errors.New("probably not a valid URL")
}
if m.Type == "playlist" {
log.Println(
cli.BgGreen, "Playlist detected", cli.Reset, m.Count, "entries",
)
entries := slices.CompactFunc(slices.Compact(m.Entries), func(a DownloadInfo, b DownloadInfo) bool {
return a.URL == b.URL
})
for i, meta := range m.Entries {
delta := time.Second.Microseconds() * int64(i+1)
logger.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries)))
for i, meta := range entries {
// detect playlist title from metadata since each playlist entry will be
// treated as an individual download
req.Rename = strings.Replace(
req.Rename,
"%(playlist_title)s",
m.PlaylistTitle,
1,
)
//TODO: it's idiotic but it works: virtually delay the creation time
meta.CreatedAt = time.Now().Add(time.Millisecond * time.Duration(i*10))
proc := &Process{
Url: meta.OriginalURL,
Url: meta.URL,
Progress: DownloadProgress{},
Output: DownloadOutput{},
Output: DownloadOutput{Filename: req.Rename},
Info: meta,
Params: req.Params,
Logger: logger,
}
proc.Info.URL = meta.OriginalURL
proc.Info.CreatedAt = time.Now().Add(time.Duration(delta))
proc.Info.URL = meta.URL
time.Sleep(time.Millisecond)
db.Set(proc)
proc.SetPending()
mq.PublishPlaylistEntry(proc)
mq.Publish(proc)
}
err = cmd.Wait()
return err
}
proc := &Process{Url: req.URL, Params: req.Params}
proc := &Process{
Url: req.URL,
Params: req.Params,
Logger: logger,
}
db.Set(proc)
mq.Publish(proc)
log.Println("Sending new process to message queue", proc.Url)
logger.Info("sending new process to message queue", slog.String("url", proc.Url))
err = cmd.Wait()
return err
return cmd.Wait()
}

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,22 +2,26 @@ package internal
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"regexp"
"slices"
"sync"
"syscall"
"github.com/goccy/go-json"
"log"
"os"
"os/exec"
"strings"
"time"
"github.com/marcopeocchi/fazzoletti/slices"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
)
const template = `download:
@@ -34,13 +38,6 @@ const (
StatusErrored
)
type ProgressTemplate struct {
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
Size string `json:"size"`
Eta int `json:"eta"`
}
// Process descriptor
type Process struct {
Id string
@@ -50,11 +47,7 @@ type Process struct {
Progress DownloadProgress
Output DownloadOutput
proc *os.Process
}
type DownloadOutput struct {
Path string
Filename string
Logger *slog.Logger
}
// Starts spawns/forks a new yt-dlp process and parse its stdout.
@@ -65,83 +58,123 @@ type DownloadOutput struct {
func (p *Process) Start() {
// escape bash variable escaping and command piping, you'll never know
// 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)
return !match
return match
})
p.Params = slices.DeleteFunc(p.Params, func(e string) bool {
return e == ""
})
out := DownloadOutput{
Path: config.Instance().GetConfig().DownloadPath,
Path: config.Instance().DownloadPath,
Filename: "%(title)s.%(ext)s",
}
if p.Output.Path != "" {
out.Path = p.Output.Path
}
if p.Output.Filename != "" {
out.Filename = p.Output.Filename + ".%(ext)s"
out.Filename = p.Output.Filename
}
params := append([]string{
buildFilename(&p.Output)
//TODO: it spawn another one yt-dlp process, too slow.
go p.GetFileName(&out)
baseParams := []string{
strings.Split(p.Url, "?list")[0], //no playlist
"--newline",
"--no-colors",
"--no-playlist",
"--progress-template", strings.ReplaceAll(template, "\n", ""),
"-o",
fmt.Sprintf("%s/%s", out.Path, out.Filename),
}, p.Params...)
"--progress-template",
strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(template),
}
// if user asked to manually override the output path...
if !(slices.Contains(p.Params, "-P") || slices.Contains(p.Params, "--paths")) {
p.Params = append(p.Params, "-o")
p.Params = append(p.Params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
}
params := append(baseParams, p.Params...)
// ----------------- main block ----------------- //
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, params...)
cmd := exec.Command(config.Instance().DownloaderPath, params...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
r, err := cmd.StdoutPipe()
if err != nil {
log.Panicln(err)
p.Logger.Error(
"failed to connect to stdout",
slog.String("err", err.Error()),
)
panic(err)
}
scan := bufio.NewScanner(r)
err = cmd.Start()
if err != nil {
log.Panicln(err)
if err := cmd.Start(); err != nil {
p.Logger.Error(
"failed to start yt-dlp process",
slog.String("err", err.Error()),
)
panic(err)
}
p.proc = cmd.Process
// ----------------- info block ----------------- //
// spawn a goroutine that retrieves the info for the download
// --------------- progress block --------------- //
// unbuffered channel connected to stdout
var (
sourceChan = make(chan []byte)
doneChan = make(chan struct{})
)
// spawn a goroutine that does the dirty job of parsing the stdout
// filling the channel with as many stdout line as yt-dlp produces (producer)
go func() {
scan := bufio.NewScanner(r)
defer func() {
r.Close()
p.Complete()
doneChan <- struct{}{}
close(sourceChan)
close(doneChan)
}()
for scan.Scan() {
stdout := ProgressTemplate{}
err := json.Unmarshal(scan.Bytes(), &stdout)
if err == nil {
p.Progress = DownloadProgress{
Status: StatusDownloading,
Percentage: stdout.Percentage,
Speed: stdout.Speed,
ETA: stdout.Eta,
}
log.Println(
cli.BgGreen, "DL", cli.Reset,
cli.BgBlue, p.getShortId(), cli.Reset,
p.Url, stdout.Percentage,
)
}
sourceChan <- scan.Bytes()
}
}()
// Slows down the unmarshal operation to every 500ms
go func() {
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
var progress ProgressTemplate
if err := json.Unmarshal(event, &progress); err != nil {
return
}
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),
)
})
}()
// ------------- end progress block ------------- //
cmd.Wait()
}
@@ -157,29 +190,30 @@ func (p *Process) Complete() {
ETA: 0,
}
shortId := p.getShortId()
log.Println(
cli.BgMagenta, "FINISH", cli.Reset,
cli.BgBlue, shortId, cli.Reset,
p.Url,
p.Logger.Info("finished",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
)
}
// Kill a process and remove it from the memory
func (p *Process) Kill() error {
defer func() {
p.Progress.Status = StatusCompleted
}()
// yt-dlp uses multiple child process the parent process
// has been spawned with setPgid = true. To properly kill
// all subprocesses a SIGTERM need to be sent to the correct
// process group
if p.proc != nil {
pgid, err := syscall.Getpgid(p.proc.Pid)
if err != nil {
return err
}
err = syscall.Kill(-pgid, syscall.SIGTERM)
if p.proc == nil {
return errors.New("*os.Process not set")
}
log.Println("Killed process", p.Id)
pgid, err := syscall.Getpgid(p.proc.Pid)
if err != nil {
return err
}
if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil {
return err
}
@@ -187,11 +221,13 @@ func (p *Process) Kill() error {
}
// Returns the available format for this URL
// TODO: Move out from process.go
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output()
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output()
if err != nil {
p.Logger.Error("failed to retrieve metadata", slog.String("err", err.Error()))
return DownloadFormats{}, err
}
@@ -205,16 +241,18 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
wg.Add(2)
if err != nil {
return DownloadFormats{}, err
}
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, "Formats", cli.Reset,
p.Url,
)
p.Logger.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", p.Url),
)
go func() {
decodingError = json.Unmarshal(stdout, &info)
wg.Done()
@@ -236,17 +274,55 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
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() {
// 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
}
func (p *Process) SetMetadata() error {
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, p.Url, "-J")
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Println("Cannot retrieve info for", p.Url)
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("url", p.Url),
slog.String("err", err.Error()),
)
return err
}
@@ -255,30 +331,46 @@ func (p *Process) SetMetadata() error {
CreatedAt: time.Now(),
}
err = cmd.Start()
if err != nil {
if err := cmd.Start(); err != nil {
return err
}
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, p.getShortId(), cli.Reset,
p.Url,
var bufferedStderr bytes.Buffer
go func() {
io.Copy(&bufferedStderr, stderr)
}()
p.Logger.Info("retrieving metadata",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
)
err = json.NewDecoder(stdout).Decode(&info)
if err != nil {
if err := json.NewDecoder(stdout).Decode(&info); err != nil {
return err
}
p.Info = info
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 {
return strings.Split(p.Id, "-")[0]
func (p *Process) getShortId() string { return strings.Split(p.Id, "-")[0] }
func buildFilename(o *DownloadOutput) {
if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") {
o.Filename += ".%(ext)s"
}
o.Filename = strings.Replace(
o.Filename,
".%(ext)s.%(ext)s",
".%(ext)s",
1,
)
}

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
}

83
server/logging/handler.go Normal file
View File

@@ -0,0 +1,83 @@
package logging
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
ReadBufferSize: 1000,
WriteBufferSize: 1000,
}
func webSocket(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for msg := range logsObservable.Observe() {
c.WriteJSON(msg.V)
}
}
func sse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
for msg := range logsObservable.Observe() {
if msg.E != nil {
http.Error(w, msg.E.Error(), http.StatusInternalServerError)
return
}
var (
b bytes.Buffer
sb strings.Builder
)
if err := json.NewEncoder(&b).Encode(msg.V); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sb.WriteString("event: log\n")
sb.WriteString("data: ")
sb.WriteString(b.String())
sb.WriteRune('\n')
sb.WriteRune('\n')
fmt.Fprint(w, sb.String())
flusher.Flush()
}
}
func ApplyRouter() func(chi.Router) {
return func(r chi.Router) {
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Get("/ws", webSocket)
r.Get("/sse", sse)
}
}

View File

@@ -0,0 +1,40 @@
package logging
import (
"time"
"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 (
logsChan = make(chan rxgo.Item, 100)
logsObservable = rxgo.
FromEventSource(logsChan, rxgo.WithBackPressureStrategy(rxgo.Drop)).
BufferWithTime(rxgo.WithDuration(time.Millisecond * 500))
)
type ObservableLogger struct{}
func NewObservableLogger() *ObservableLogger {
return &ObservableLogger{}
}
func (o *ObservableLogger) Write(p []byte) (n int, err error) {
logsChan <- rxgo.Of(string(p))
n = len(p)
err = nil
return
}

View File

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

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

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

View File

@@ -1,9 +1,10 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/goccy/go-json"
"github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
@@ -11,15 +12,19 @@ type Handler struct {
service *Service
}
/*
REST version of the JSON-RPC interface
*/
func (h *Handler) Exec() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
req := internal.DownloadRequest{}
var req internal.DownloadRequest
if err := json.NewDecoder(r.Body).DecodeContext(r.Context(), &req); err != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -29,7 +34,7 @@ func (h *Handler) Exec() http.HandlerFunc {
return
}
err = json.NewEncoder(w).EncodeContext(r.Context(), id)
err = json.NewEncoder(w).Encode(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -48,7 +53,7 @@ func (h *Handler) Running() http.HandlerFunc {
return
}
err = json.NewEncoder(w).EncodeContext(r.Context(), res)
err = json.NewEncoder(w).Encode(res)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -63,7 +68,7 @@ func (h *Handler) SetCookies() http.HandlerFunc {
req := new(internal.SetCookiesRequest)
err := json.NewDecoder(r.Body).DecodeContext(r.Context(), req)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -75,9 +80,99 @@ func (h *Handler) SetCookies() http.HandlerFunc {
return
}
err = json.NewEncoder(w).EncodeContext(r.Context(), "ok")
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) AddTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
req := new(internal.CustomTemplate)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Name == "" || req.Content == "" {
http.Error(w, "Invalid template", http.StatusBadRequest)
return
}
err = h.service.SaveTemplate(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) GetTemplates() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
templates, err := h.service.GetTemplates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(templates)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) DeleteTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
id := chi.URLParam(r, "id")
err := h.service.DeleteTemplate(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) GetVersion() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
version, err := h.service.GetVersion(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(version); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

View File

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

View File

@@ -2,15 +2,23 @@ package rest
import (
"context"
"database/sql"
"errors"
"log/slog"
"os"
"os/exec"
"time"
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
type Service struct {
db *internal.MemoryDB
mq *internal.MessageQueue
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
logger *slog.Logger
}
func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
@@ -21,9 +29,10 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
Path: req.Path,
Filename: req.Rename,
},
Logger: s.logger,
}
id := s.db.Set(p)
id := s.mdb.Set(p)
s.mq.Publish(p)
return id, nil
@@ -34,7 +43,7 @@ func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, err
case <-ctx.Done():
return nil, errors.New("context cancelled")
default:
return s.db.All(), nil
return s.mdb.All(), nil
}
}
@@ -49,3 +58,86 @@ func (s *Service) SetCookies(ctx context.Context, cookies string) error {
return nil
}
func (s *Service) SaveTemplate(ctx context.Context, template *internal.CustomTemplate) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(
ctx,
"INSERT INTO templates (id, name, content) VALUES (?, ?, ?)",
uuid.NewString(),
template.Name,
template.Content,
)
return err
}
func (s *Service) GetTemplates(ctx context.Context) (*[]internal.CustomTemplate, error) {
conn, err := s.db.Conn(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
rows, err := conn.QueryContext(ctx, "SELECT * FROM templates")
if err != nil {
return nil, err
}
defer rows.Close()
templates := make([]internal.CustomTemplate, 0)
for rows.Next() {
t := internal.CustomTemplate{}
err := rows.Scan(&t.Id, &t.Name, &t.Content)
if err != nil {
return nil, err
}
templates = append(templates, t)
}
return &templates, nil
}
func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(ctx, "DELETE FROM templates WHERE id = ?", id)
return err
}
func (s *Service) GetVersion(ctx context.Context) (string, error) {
ch := make(chan string, 1)
c, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
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
}
}

View File

@@ -1,23 +1,33 @@
package rpc
import (
"log/slog"
"github.com/go-chi/chi/v5"
"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"
)
// Dependency injection container.
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
func Container(
db *internal.MemoryDB,
mq *internal.MessageQueue,
logger *slog.Logger,
) *Service {
return &Service{
db: db,
mq: mq,
db: db,
mq: mq,
logger: logger,
}
}
// RPC service must be registered before applying this router!
func ApplyRouter() func(chi.Router) {
return func(r chi.Router) {
r.Use(middlewares.Authenticated)
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Get("/ws", WebSocket)
r.Post("/http", Post)
}

View File

@@ -13,6 +13,7 @@ var upgrader = websocket.Upgrader{
},
}
// WebSockets JSON-RPC handler
func WebSocket(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
@@ -28,7 +29,6 @@ func WebSocket(w http.ResponseWriter, r *http.Request) {
for {
mtype, reader, err := c.NextReader()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
break
}
@@ -44,6 +44,7 @@ func WebSocket(w http.ResponseWriter, r *http.Request) {
}
}
// HTTP-POST JSON-RPC handler
func Post(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()

View File

@@ -1,7 +1,8 @@
package rpc
import (
"log"
"errors"
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
@@ -9,8 +10,9 @@ import (
)
type Service struct {
db *internal.MemoryDB
mq *internal.MessageQueue
db *internal.MemoryDB
mq *internal.MessageQueue
logger *slog.Logger
}
type Running []internal.ProcessResponse
@@ -34,6 +36,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
Path: args.Path,
Filename: args.Rename,
},
Logger: s.logger,
}
s.db.Set(p)
@@ -46,13 +49,12 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
// Exec spawns a Process.
// The result of the execution is the newly spawned process Id.
func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error {
err := internal.PlaylistDetect(args, s.mq, s.db)
err := internal.PlaylistDetect(args, s.mq, s.db, s.logger)
if err != nil {
return err
}
*result = ""
return nil
}
@@ -62,14 +64,17 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
if err != nil {
return err
}
*progress = proc.Progress
return nil
}
// Progess retrieves available format for a given resource
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
var err error
p := internal.Process{Url: args.URL}
var (
err error
p = internal.Process{Url: args.URL, Logger: s.logger}
)
*meta, err = p.GetFormatsSync()
return err
}
@@ -88,44 +93,70 @@ func (s *Service) Running(args NoArgs, running *Running) error {
// Kill kills a process given its id and remove it from the memoryDB
func (s *Service) Kill(args string, killed *string) error {
log.Println("Trying killing process with id", args)
proc, err := s.db.Get(args)
s.logger.Info("Trying killing process with id", slog.String("id", args))
proc, err := s.db.Get(args)
if err != nil {
return err
}
if proc != nil {
err = proc.Kill()
s.db.Delete(proc.Id)
if proc == nil {
return errors.New("nil process")
}
if err := proc.Kill(); err != nil {
s.logger.Info("failed killing process", slog.String("id", proc.Id), slog.Any("err", err))
return err
}
s.db.Delete(proc.Id)
return err
s.logger.Info("succesfully killed process", slog.String("id", proc.Id))
return nil
}
// KillAll kills all process unconditionally and removes them from
// the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error {
log.Println("Killing all spawned processes", args)
keys := s.db.Keys()
var err error
s.logger.Info("Killing all spawned processes")
var (
keys = s.db.Keys()
removeFunc = func(p *internal.Process) error {
defer s.db.Delete(p.Id)
return p.Kill()
}
)
for _, key := range *keys {
proc, err := s.db.Get(key)
if err != nil {
return err
}
if proc != nil {
proc.Kill()
s.db.Delete(proc.Id)
if proc == nil {
s.db.Delete(key)
continue
}
if err := removeFunc(proc); err != nil {
s.logger.Info(
"failed killing process",
slog.String("id", proc.Id),
slog.Any("err", err),
)
continue
}
s.logger.Info("succesfully killed process", slog.String("id", proc.Id))
}
s.mq.Empty()
return err
return nil
}
// Remove a process from the db rendering it unusable if active
func (s *Service) Clear(args string, killed *string) error {
log.Println("Clearing process with id", args)
s.logger.Info("Clearing process with id", slog.String("id", args))
s.db.Delete(args)
return nil
}
@@ -133,6 +164,10 @@ func (s *Service) Clear(args string, killed *string) error {
// FreeSpace gets the available from package sys util
func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
freeSpace, err := sys.FreeSpace()
if err != nil {
return err
}
*free = freeSpace
return err
}
@@ -140,20 +175,31 @@ func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
// Return a flattned tree of the download directory
func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
dfsTree, err := sys.DirectoryTree()
if err != nil {
*tree = nil
return err
}
if dfsTree != nil {
*tree = *dfsTree
}
return err
return nil
}
// Updates the yt-dlp binary using its builtin function
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
log.Println("Updating yt-dlp executable to the latest release")
err := updater.UpdateExecutable()
if err != nil {
*updated = true
s.logger.Info("Updating yt-dlp executable to the latest release")
if err := updater.UpdateExecutable(); err != nil {
s.logger.Error("Failed updating yt-dlp")
*updated = false
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"
)
// Wrapper for HTTP RPC request that implements io.Reader interface
// Wrapper for jsonrpc.ServeConn that simplifies its usage
type rpcRequest struct {
r io.Reader
rw io.ReadWriter
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 {
var buf bytes.Buffer
done := make(chan bool)

View File

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

View File

@@ -2,72 +2,177 @@ package server
import (
"context"
"database/sql"
"fmt"
"io"
"io/fs"
"log"
"log/slog"
"net"
"net/http"
"net/rpc"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"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/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
_ "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 {
frontend fs.FS
swagger fs.FS
logger *slog.Logger
host string
port int
db *internal.MemoryDB
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
}
func RunBlocking(port int, frontend fs.FS) {
var db internal.MemoryDB
db.Restore()
func RunBlocking(cfg *RunConfig) {
var mdb internal.MemoryDB
mq := internal.NewMessageQueue()
go mq.Subscriber()
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(
slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}),
)
db, err := sql.Open("sqlite", cfg.DBPath)
if err != nil {
logger.Error("failed to open database", slog.String("err", err.Error()))
}
if err := dbutil.AutoMigrate(context.Background(), db); err != nil {
logger.Error("failed to init database", slog.String("err", err.Error()))
}
mq, err := internal.NewMessageQueue(logger)
if err != nil {
panic(err)
}
mq.SetupConsumers()
go mdb.Restore(mq, logger)
srv := newServer(serverConfig{
frontend: frontend,
port: port,
db: &db,
frontend: cfg.App,
swagger: cfg.Swagger,
logger: logger,
host: cfg.Host,
port: cfg.Port,
mdb: &mdb,
mq: mq,
db: db,
})
go gracefulShutdown(srv, &db)
go autoPersist(time.Minute*5, &db)
go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &mdb, logger)
log.Fatal(srv.ListenAndServe())
var (
network = "tcp"
address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
)
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()))
}
}
func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.db, c.mq)
service := ytdlpRPC.Container(c.mdb, c.mq, c.logger)
rpc.Register(service)
r := chi.NewRouter()
r.Use(cors.AllowAll().Handler)
r.Use(middleware.Logger)
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
})
app := http.FileServer(http.FS(c.frontend))
r.Use(corsMiddleware.Handler)
// use in dev
// r.Use(middleware.Logger)
r.Mount("/", app)
baseUrl := config.Instance().BaseURL
r.Mount(baseUrl+"/", http.StripPrefix(baseUrl, http.FileServerFS(c.frontend)))
// swagger
r.Mount("/openapi", http.FileServerFS(c.swagger))
// Archive routes
r.Route("/archive", func(r chi.Router) {
r.Use(middlewares.Authenticated)
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Post("/downloaded", handlers.ListDownloaded)
r.Post("/delete", handlers.DeleteFile)
r.Get("/d/{id}", handlers.SendFile)
r.Get("/d/{id}", handlers.DownloadFile)
r.Get("/v/{id}", handlers.SendFile)
r.Get("/bulk", handlers.BulkDownload(c.mdb))
})
// Authentication routes
@@ -80,12 +185,17 @@ func newServer(c serverConfig) *http.Server {
r.Route("/rpc", ytdlpRPC.ApplyRouter())
// REST API handlers
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mq))
r.Route("/api/v1", rest.ApplyRouter(&rest.ContainerArgs{
DB: c.db,
MDB: c.mdb,
MQ: c.mq,
Logger: c.logger,
}))
return &http.Server{
Addr: fmt.Sprintf(":%d", c.port),
Handler: r,
}
// Logging
r.Route("/log", logging.ApplyRouter())
return &http.Server{Handler: r}
}
func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
@@ -97,19 +207,25 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
go func() {
<-ctx.Done()
log.Println("shutdown signal received")
slog.Info("shutdown signal received")
defer func() {
db.Persist()
stop()
srv.Shutdown(context.TODO())
srv.Shutdown(context.Background())
}()
}()
}
func autoPersist(d time.Duration, db *internal.MemoryDB) {
func autoPersist(d time.Duration, db *internal.MemoryDB, logger *slog.Logger) {
for {
db.Persist()
if err := db.Persist(); err != nil {
logger.Info(
"failed to persisted session",
slog.String("err", err.Error()),
)
}
logger.Info("sucessfully persisted session")
time.Sleep(d)
}
}

View File

@@ -14,7 +14,7 @@ import (
// FreeSpace gets the available Bytes writable to download directory
func FreeSpace() (uint64, error) {
var stat unix.Statfs_t
unix.Statfs(config.Instance().GetConfig().DownloadPath, &stat)
unix.Statfs(config.Instance().DownloadPath, &stat)
return (stat.Bavail * uint64(stat.Bsize)), nil
}
@@ -26,10 +26,12 @@ func DirectoryTree() (*[]string, error) {
children []Node
}
rootPath := config.Instance().GetConfig().DownloadPath
var (
rootPath = config.Instance().DownloadPath
stack := internal.NewStack[Node]()
flattened := make([]string, 0)
stack = internal.NewStack[Node]()
flattened = make([]string, 0)
)
stack.Push(Node{path: rootPath})
@@ -37,14 +39,16 @@ func DirectoryTree() (*[]string, error) {
for stack.IsNotEmpty() {
current := stack.Pop().Value
children, err := os.ReadDir(current.path)
if err != nil {
return nil, err
}
for _, entry := range children {
childPath := filepath.Join(current.path, entry.Name())
childNode := Node{path: childPath}
var (
childPath = filepath.Join(current.path, entry.Name())
childNode = Node{path: childPath}
)
if entry.IsDir() {
current.children = append(current.children, childNode)
stack.Push(childNode)

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