Compare commits

...

47 Commits

Author SHA1 Message Date
c4075fb640 ready for 3.2.0 2024-08-21 11:43:55 +02:00
Aaron Gershman
aa8191b0cd filemame to filename (#182)
Typo on filemame fixed to filename
2024-08-21 11:29:10 +02:00
a6626973ac apply loading backdrop when loading livestreams 2024-08-21 11:23:34 +02:00
79f1473c6a fixed livestream process not properly killed 2024-08-21 11:16:44 +02:00
Marco Piovanello
b76f2b72be Update README.md 2024-08-20 20:31:39 +02:00
8f2d9eaf6e code refactoring 2024-08-20 20:29:32 +02:00
ja49619
c51f320a6f Update i18n.yaml (#181)
Update translation for i18n.yaml to RU language
2024-08-20 20:14:27 +02:00
8b26bf513f code refactoring 2024-08-20 19:11:54 +02:00
25210ccc22 code refactoring 2024-08-20 19:04:10 +02:00
3205711bb1 improved livestream waiting 2024-08-20 18:50:42 +02:00
92e3fd994e code refactoring / fix typos 2024-08-20 09:42:04 +02:00
01e9da61eb code refactoring 2024-08-19 22:24:38 +02:00
Marco Piovanello
fd5e62e23b Feat livestream support (#180)
* experimental livestrea support

* test livestream

* update wait time detection

* update livestream functions

* persist and restore livestreams monitor session

* fan-in logging

* deps update

* added live time display

* livestream monitor prototype

* changed to default logger instead of passing *slog.Logger everywhere

* code refactoring, comments
2024-08-19 22:08:09 +02:00
a64798644a code refactoring
fixed bad escape in i18n.yaml
2024-08-19 10:25:25 +02:00
Emanuel Johnson Godin
b7511eb064 i18n: add swedish (#179)
* i18n: add swedish

* Fix spelling mistakes and minor rewording
2024-08-19 10:08:50 +02:00
Marco Piovanello
16f8f74f9b Delete frontend/yarn.lock 2024-08-16 14:17:14 +02:00
61891839d9 added info in templates editor 2024-08-01 11:06:10 +02:00
Marco Piovanello
17cd2d54d0 corrected typo (#174) 2024-07-31 15:07:14 +02:00
9b61436a05 openid code refactoring 2024-07-24 09:58:37 +02:00
31e3cfab24 code refactoring 2024-07-23 19:23:51 +02:00
ed437ec367 oid configuration hotfix 2024-07-23 19:23:13 +02:00
Marco Piovanello
92aabc0086 OpenID authentification (#170)
* openid authentification

* openid middleware

* openId login

* tidied login page

* removed useless email text field
2024-07-23 19:04:05 +02:00
38a0cedc9c fixed empty url in format selection
Closes #163
2024-07-14 15:52:36 +02:00
c0c2fcb009 fix "default templates are re-added upon restart"
Mentioned in #161
2024-07-08 10:19:44 +02:00
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
68 changed files with 2253 additions and 854 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.tsx linguist-detectable=false

5
.gitignore vendored
View File

@@ -16,3 +16,8 @@ cookies.txt
__debug* __debug*
ui/ ui/
.idea .idea
frontend/.pnp.cjs
frontend/.pnp.loader.mjs
frontend/.yarn/install-state.gz
.db.lock
livestreams.dat

View File

@@ -25,15 +25,13 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# dependencies ---------------------------------------------------------------- # dependencies ----------------------------------------------------------------
FROM cgr.dev/chainguard/wolfi-base FROM alpine:edge
RUN apk update && \ RUN apk update && \
apk add ffmpeg ca-certificates python3 py3-pip wget apk add ffmpeg yt-dlp ca-certificates curl wget psmisc
VOLUME /downloads /config VOLUME /downloads /config
RUN python3 -m pip install yt-dlp
WORKDIR /app WORKDIR /app
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app

View File

@@ -1,11 +1,14 @@
.PHONY : fe clean all .PHONY : fe clean all
default: default:
CGO_ENABLED=0 go build -o yt-dlp-webui main.go go run main.go
fe: fe:
cd frontend && pnpm build cd frontend && pnpm build
dev:
cd frontend && pnpm dev
all: all:
$(MAKE) fe && cd .. $(MAKE) fe && cd ..
CGO_ENABLED=0 go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go

View File

@@ -1,8 +1,3 @@
> [!IMPORTANT]
> Major frontend refactoring in progress.
> I won't add features or fix minor issues until completition.
---
# yt-dlp Web UI # yt-dlp Web UI
A not so terrible web ui for yt-dlp. A not so terrible web ui for yt-dlp.

View File

@@ -5,6 +5,7 @@ services:
- 3033:3033 - 3033:3033
volumes: volumes:
- <your dir>:/downloads # replace <your dir> with a directory on your host system - <your dir>:/downloads # replace <your dir> with a directory on your host system
- <your dir>:/config # directory where config.yml will be stored
healthcheck: healthcheck:
test: curl -f http://localhost:3033 || exit 1 test: curl -f http://localhost:3033 || exit 1
restart: unless-stopped 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 ];
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "3.0.8", "version": "3.2.0",
"description": "Frontend compontent of yt-dlp-webui", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -19,20 +19,21 @@
"fp-ts": "^2.16.5", "fp-ts": "^2.16.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.23.0", "react-router-dom": "^6.23.1",
"react-virtuoso": "^4.7.11",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/node": "^20.11.4", "@types/node": "^20.14.2",
"@types/react": "^18.2.48", "@types/react": "^18.3.3",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/react-helmet": "^6.1.11", "@types/react-helmet": "^6.1.11",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.7.0",
"typescript": "^5.4.3", "million": "^3.1.11",
"vite": "^5.2.11", "typescript": "^5.4.5",
"million": "^3.0.6" "vite": "^5.2.11"
} }
} }

324
frontend/pnpm-lock.yaml generated
View File

@@ -10,10 +10,10 @@ importers:
dependencies: dependencies:
'@emotion/react': '@emotion/react':
specifier: ^11.11.4 specifier: ^11.11.4
version: 11.11.4(@types/react@18.2.48)(react@18.3.1) version: 11.11.4(@types/react@18.3.3)(react@18.3.1)
'@emotion/styled': '@emotion/styled':
specifier: ^11.11.5 specifier: ^11.11.5
version: 11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1) version: 11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)
'@fontsource/roboto': '@fontsource/roboto':
specifier: ^5.0.13 specifier: ^5.0.13
version: 5.0.13 version: 5.0.13
@@ -22,10 +22,10 @@ importers:
version: 5.0.18 version: 5.0.18
'@mui/icons-material': '@mui/icons-material':
specifier: ^5.15.16 specifier: ^5.15.16
version: 5.15.16(@mui/material@5.15.16(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.2.48)(react@18.3.1) version: 5.15.16(@mui/material@5.15.16(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)
'@mui/material': '@mui/material':
specifier: ^5.15.16 specifier: ^5.15.16
version: 5.15.16(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 5.15.16(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
fp-ts: fp-ts:
specifier: ^2.16.5 specifier: ^2.16.5
version: 2.16.5 version: 2.16.5
@@ -36,8 +36,11 @@ importers:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-router-dom: react-router-dom:
specifier: ^6.23.0 specifier: ^6.23.1
version: 6.23.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-virtuoso:
specifier: ^4.7.11
version: 4.7.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recoil: recoil:
specifier: ^0.7.7 specifier: ^0.7.7
version: 0.7.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 0.7.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -47,13 +50,13 @@ importers:
devDependencies: devDependencies:
'@modyfi/vite-plugin-yaml': '@modyfi/vite-plugin-yaml':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0(rollup@4.13.0)(vite@5.2.11(@types/node@20.11.4)) version: 1.1.0(rollup@4.13.0)(vite@5.2.11(@types/node@20.14.2))
'@types/node': '@types/node':
specifier: ^20.11.4 specifier: ^20.14.2
version: 20.11.4 version: 20.14.2
'@types/react': '@types/react':
specifier: ^18.2.48 specifier: ^18.3.3
version: 18.2.48 version: 18.3.3
'@types/react-dom': '@types/react-dom':
specifier: ^18.2.18 specifier: ^18.2.18
version: 18.2.18 version: 18.2.18
@@ -64,17 +67,17 @@ importers:
specifier: ^5.3.3 specifier: ^5.3.3
version: 5.3.3 version: 5.3.3
'@vitejs/plugin-react-swc': '@vitejs/plugin-react-swc':
specifier: ^3.6.0 specifier: ^3.7.0
version: 3.6.0(vite@5.2.11(@types/node@20.11.4)) version: 3.7.0(vite@5.2.11(@types/node@20.14.2))
million: million:
specifier: ^3.0.6 specifier: ^3.1.11
version: 3.0.6(rollup@4.13.0) version: 3.1.11(rollup@4.13.0)
typescript: typescript:
specifier: ^5.4.3 specifier: ^5.4.5
version: 5.4.3 version: 5.4.5
vite: vite:
specifier: ^5.2.11 specifier: ^5.2.11
version: 5.2.11(@types/node@20.11.4) version: 5.2.11(@types/node@20.14.2)
packages: packages:
@@ -518,8 +521,8 @@ packages:
'@popperjs/core@2.11.8': '@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@remix-run/router@1.16.0': '@remix-run/router@1.16.1':
resolution: {integrity: sha512-Quz1KOffeEf/zwkCBM3kBtH4ZoZ+pT3xIXBG4PPW/XFtDP7EGhtTiC2+gpL9GnR7+Qdet5Oa6cYSvwKYg6kN9Q==} resolution: {integrity: sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
'@rollup/pluginutils@5.1.0': '@rollup/pluginutils@5.1.0':
@@ -596,80 +599,80 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@swc/core-darwin-arm64@1.4.8': '@swc/core-darwin-arm64@1.5.28':
resolution: {integrity: sha512-hhQCffRTgzpTIbngSnC30vV6IJVTI9FFBF954WEsshsecVoCGFiMwazBbrkLG+RwXENTrMhgeREEFh6R3KRgKQ==} resolution: {integrity: sha512-sP6g63ybzIdOWNDbn51tyHN8EMt7Mb4RMeHQEsXB7wQfDvzhpWB+AbfK6Gs3Q8fwP/pmWIrWW9csKOc1K2Mmkg==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@swc/core-darwin-x64@1.4.8': '@swc/core-darwin-x64@1.5.28':
resolution: {integrity: sha512-P3ZBw8Jr8rKhY/J8d+6WqWriqngGTgHwtFeJ8MIakQJTbdYbFgXSZxcvDiERg3psbGeFXaUaPI0GO6BXv9k/OQ==} resolution: {integrity: sha512-Bd/agp/g7QocQG5AuorOzSC78t8OzeN+pCN/QvJj1CvPhvppjJw6e1vAbOR8vO2vvGi2pvtf3polrYQStJtSiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.4.8': '@swc/core-linux-arm-gnueabihf@1.5.28':
resolution: {integrity: sha512-PP9JIJt19bUWhAGcQW6qMwTjZOcMyzkvZa0/LWSlDm0ORYVLmDXUoeQbGD3e0Zju9UiZxyulnpjEN0ZihJgPTA==} resolution: {integrity: sha512-Wr3TwPGIveS9/OBWm0r9VAL8wkCR0zQn46J8K01uYCmVhUNK3Muxjs0vQBZaOrGu94mqbj9OXY+gB3W7aDvGdA==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@swc/core-linux-arm64-gnu@1.4.8': '@swc/core-linux-arm64-gnu@1.5.28':
resolution: {integrity: sha512-HvEWnwKHkoVUr5iftWirTApFJ13hGzhAY2CMw4lz9lur2m+zhPviRRED0FCI6T95Knpv7+8eUOr98Z7ctrG6DQ==} resolution: {integrity: sha512-8G1ZwVTuLgTAVTMPD+M97eU6WeiRIlGHwKZ5fiJHPBcz1xqIC7jQcEh7XBkobkYoU5OILotls3gzjRt8CMNyDQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@swc/core-linux-arm64-musl@1.4.8': '@swc/core-linux-arm64-musl@1.5.28':
resolution: {integrity: sha512-kY8+qa7k/dEeBq9p0Hrta18QnJPpsiJvDQSLNaTIFpdM3aEM9zbkshWz8gaX5VVGUEALowCBUWqmzO4VaqM+2w==} resolution: {integrity: sha512-0Ajdzb5Fzvz+XUbN5ESeHAz9aHHSYiQcm+vmsDi0TtPHmsalfnqEPZmnK0zPALPJPLQP2dDo4hELeDg3/c3xgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@swc/core-linux-x64-gnu@1.4.8': '@swc/core-linux-x64-gnu@1.5.28':
resolution: {integrity: sha512-0WWyIw432wpO/zeGblwq4f2YWam4pn8Z/Ig4KzHMgthR/KmiLU3f0Z7eo45eVmq5vcU7Os1zi/Zb65OOt09q/w==} resolution: {integrity: sha512-ueQ9VejnQUM2Pt+vT0IAKoF4vYBWUP6n1KHGdILpoGe3LuafQrqu7RoyQ15C7/AYii7hAeNhTFdf6gLbg8cjFg==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@swc/core-linux-x64-musl@1.4.8': '@swc/core-linux-x64-musl@1.5.28':
resolution: {integrity: sha512-p4yxvVS05rBNCrBaSTa20KK88vOwtg8ifTW7ec/yoab0bD5EwzzB8KbDmLLxE6uziFa0sdjF0dfRDwSZPex37Q==} resolution: {integrity: sha512-G5th8Mg0az8CbY4GQt9/m5hg2Y0kGIwvQBeVACuLQB6q2Y4txzdiTpjmFqUUhEvvl7Klyx1IHvNhfXs3zpt7PA==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@swc/core-win32-arm64-msvc@1.4.8': '@swc/core-win32-arm64-msvc@1.5.28':
resolution: {integrity: sha512-jKuXihxAaqUnbFfvPxtmxjdJfs87F1GdBf33il+VUmSyWCP4BE6vW+/ReDAe8sRNsKyrZ3UH1vI5q1n64csBUA==} resolution: {integrity: sha512-JezwCGavZ7CkNXx4yInI4kpb71L0zxzxA9BFlmnsGKEEjVQcKc3hFpmIzfFVs+eotlBUwDNb0+Yo9m6Cb7lllA==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@swc/core-win32-ia32-msvc@1.4.8': '@swc/core-win32-ia32-msvc@1.5.28':
resolution: {integrity: sha512-O0wT4AGHrX8aBeH6c2ADMHgagAJc5Kf6W48U5moyYDAkkVnKvtSc4kGhjWhe1Yl0sI0cpYh2In2FxvYsb44eWw==} resolution: {integrity: sha512-q8tW5J4RkOkl7vYShnWS//VAb2Ngolfm9WOMaF2GRJUr2Y/Xeb/+cNjdsNOqea2BzW049D5vdP7XPmir3/zUZw==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@swc/core-win32-x64-msvc@1.4.8': '@swc/core-win32-x64-msvc@1.5.28':
resolution: {integrity: sha512-C2AYc3A2o+ECciqsJWRgIpp83Vk5EaRzHe7ed/xOWzVd0MsWR+fweEsyOjlmzHfpUxJSi46Ak3/BIZJlhZbXbg==} resolution: {integrity: sha512-jap6EiB3wG1YE1hyhNr9KLPpH4PGm+5tVMfN0l7fgKtV0ikgpcEN/YF94tru+z5m2HovqYW009+Evq9dcVGmpg==}
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@swc/core@1.4.8': '@swc/core@1.5.28':
resolution: {integrity: sha512-uY2RSJcFPgNOEg12RQZL197LZX+MunGiKxsbxmh22VfVxrOYGRvh4mPANFlrD1yb38CgmW1wI6YgIi8LkIwmWg==} resolution: {integrity: sha512-muCdNIqOTURUgYeyyOLYE3ShL8SZO6dw6bhRm6dCvxWzCZOncPc5fB0kjcPXTML+9KJoHL7ks5xg+vsQK+v6ig==}
engines: {node: '>=10'} engines: {node: '>=10'}
peerDependencies: peerDependencies:
'@swc/helpers': ^0.5.0 '@swc/helpers': '*'
peerDependenciesMeta: peerDependenciesMeta:
'@swc/helpers': '@swc/helpers':
optional: true optional: true
'@swc/counter@0.1.2': '@swc/counter@0.1.3':
resolution: {integrity: sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==} resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/types@0.1.5': '@swc/types@0.1.8':
resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==} resolution: {integrity: sha512-RNFA3+7OJFNYY78x0FYwi1Ow+iF1eF5WvmfY1nXPOEH4R2p/D4Cr1vzje7dNAI2aLFqpv8Wyz4oKSWqIZArpQA==}
'@types/estree@1.0.5': '@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -677,8 +680,8 @@ packages:
'@types/history@4.7.11': '@types/history@4.7.11':
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
'@types/node@20.11.4': '@types/node@20.14.2':
resolution: {integrity: sha512-6I0fMH8Aoy2lOejL3s4LhyIYX34DPwY8bl5xlNjBvUEk8OHrcuzsFt+Ied4LvJihbtXPM+8zUqdydfIti86v9g==} resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==}
'@types/parse-json@4.0.0': '@types/parse-json@4.0.0':
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
@@ -686,9 +689,6 @@ packages:
'@types/prop-types@15.7.11': '@types/prop-types@15.7.11':
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
'@types/prop-types@15.7.5':
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
'@types/react-dom@18.2.18': '@types/react-dom@18.2.18':
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==} resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
@@ -704,14 +704,11 @@ packages:
'@types/react-transition-group@4.4.10': '@types/react-transition-group@4.4.10':
resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==} resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==}
'@types/react@18.2.48': '@types/react@18.3.3':
resolution: {integrity: sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==} resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==}
'@types/scheduler@0.16.3': '@vitejs/plugin-react-swc@3.7.0':
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} resolution: {integrity: sha512-yrknSb3Dci6svCd/qhHqhFPDSw0QtjumcqdKMoNNzmOl5lMXTTiqzjWtG4Qask2HdvvzaNgSunbQGet8/GrKdA==}
'@vitejs/plugin-react-swc@3.6.0':
resolution: {integrity: sha512-XFRbsGgpGxGzEV5i5+vRiro1bwcIaZDIdBRP16qwm+jP68ue/S8FJTBEgOeojtVDYrbSua3XFp71kC8VJE6v+g==}
peerDependencies: peerDependencies:
vite: ^4 || ^5 vite: ^4 || ^5
@@ -931,8 +928,8 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
million@3.0.6: million@3.1.11:
resolution: {integrity: sha512-OLjRVASGOZdyZw2ctBSSOu5kb9PaxafqkueqVvw0iQtUUnTLVRk1EmtqcNAtJWCIm8wn+WGRpDbnp+5Hi8//Kg==} resolution: {integrity: sha512-6Vh1s0da0PzSqbbp9Zd8yMTIkOWnvBU4vNJCMHTZPXaY3fZ5h+N7s5croS/RBgjJIHz3WQZnvyNBQz7gQ6cqJg==}
hasBin: true hasBin: true
ms@2.1.2: ms@2.1.2:
@@ -994,15 +991,15 @@ packages:
react-is@18.2.0: react-is@18.2.0:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
react-router-dom@6.23.0: react-router-dom@6.23.1:
resolution: {integrity: sha512-Q9YaSYvubwgbal2c9DJKfx6hTNoBp3iJDsl+Duva/DwxoJH+OTXkxGpql4iUK2sla/8z4RpjAm6EWx1qUDuopQ==} resolution: {integrity: sha512-utP+K+aSTtEdbWpC+4gxhdlPFwuEfDKq8ZrPFU65bbRJY+l706qjR7yaidBpo3MSeA/fzwbXWbKBI6ftOnP3OQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
react: '>=16.8' react: '>=16.8'
react-dom: '>=16.8' react-dom: '>=16.8'
react-router@6.23.0: react-router@6.23.1:
resolution: {integrity: sha512-wPMZ8S2TuPadH0sF5irFGjkNLIcRvOSaEe7v+JER8508dyJumm6XZB1u5kztlX0RVq6AzRVndzqcUh6sFIauzA==} resolution: {integrity: sha512-fzcOaRF69uvqbbM7OhvQyBTFDVrrGlsFdS3AL+1KfIBtGETibHzi3FkoTRyiDJnWNc2VxrfvR+657ROHjaNjqQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
react: '>=16.8' react: '>=16.8'
@@ -1013,6 +1010,13 @@ packages:
react: '>=16.6.0' react: '>=16.6.0'
react-dom: '>=16.6.0' react-dom: '>=16.6.0'
react-virtuoso@4.7.11:
resolution: {integrity: sha512-Kdn9qEtQI2ulEuBMzW2BTkDsfijB05QUd6lpZ1K36oyA3k65Cz4lG4EKrh2pCfUafX4C2uMSZOwzMOhbrMOTFA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16 || >=17 || >= 18'
react-dom: '>=16 || >=17 || >= 18'
react@18.3.1: react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1093,8 +1097,8 @@ packages:
tslib@2.5.2: tslib@2.5.2:
resolution: {integrity: sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==} resolution: {integrity: sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==}
typescript@5.4.3: typescript@5.4.5:
resolution: {integrity: sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==} resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
@@ -1326,7 +1330,7 @@ snapshots:
'@emotion/memoize@0.8.1': {} '@emotion/memoize@0.8.1': {}
'@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1)': '@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.23.8 '@babel/runtime': 7.23.8
'@emotion/babel-plugin': 11.11.0 '@emotion/babel-plugin': 11.11.0
@@ -1338,7 +1342,7 @@ snapshots:
hoist-non-react-statics: 3.3.2 hoist-non-react-statics: 3.3.2
react: 18.3.1 react: 18.3.1
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@emotion/serialize@1.1.3': '@emotion/serialize@1.1.3':
dependencies: dependencies:
@@ -1358,18 +1362,18 @@ snapshots:
'@emotion/sheet@1.2.2': {} '@emotion/sheet@1.2.2': {}
'@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1)': '@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.23.8 '@babel/runtime': 7.23.8
'@emotion/babel-plugin': 11.11.0 '@emotion/babel-plugin': 11.11.0
'@emotion/is-prop-valid': 1.2.2 '@emotion/is-prop-valid': 1.2.2
'@emotion/react': 11.11.4(@types/react@18.2.48)(react@18.3.1) '@emotion/react': 11.11.4(@types/react@18.3.3)(react@18.3.1)
'@emotion/serialize': 1.1.4 '@emotion/serialize': 1.1.4
'@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.3.1) '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.3.1)
'@emotion/utils': 1.2.1 '@emotion/utils': 1.2.1
react: 18.3.1 react: 18.3.1
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@emotion/unitless@0.8.1': {} '@emotion/unitless@0.8.1': {}
@@ -1488,47 +1492,47 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.1 '@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
'@modyfi/vite-plugin-yaml@1.1.0(rollup@4.13.0)(vite@5.2.11(@types/node@20.11.4))': '@modyfi/vite-plugin-yaml@1.1.0(rollup@4.13.0)(vite@5.2.11(@types/node@20.14.2))':
dependencies: dependencies:
'@rollup/pluginutils': 5.1.0(rollup@4.13.0) '@rollup/pluginutils': 5.1.0(rollup@4.13.0)
js-yaml: 4.1.0 js-yaml: 4.1.0
tosource: 2.0.0-alpha.3 tosource: 2.0.0-alpha.3
vite: 5.2.11(@types/node@20.11.4) vite: 5.2.11(@types/node@20.14.2)
transitivePeerDependencies: transitivePeerDependencies:
- rollup - rollup
'@mui/base@5.0.0-beta.40(@types/react@18.2.48)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@mui/base@5.0.0-beta.40(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.5 '@babel/runtime': 7.24.5
'@floating-ui/react-dom': 2.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@floating-ui/react-dom': 2.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mui/types': 7.2.14(@types/react@18.2.48) '@mui/types': 7.2.14(@types/react@18.3.3)
'@mui/utils': 5.15.14(@types/react@18.2.48)(react@18.3.1) '@mui/utils': 5.15.14(@types/react@18.3.3)(react@18.3.1)
'@popperjs/core': 2.11.8 '@popperjs/core': 2.11.8
clsx: 2.1.0 clsx: 2.1.0
prop-types: 15.8.1 prop-types: 15.8.1
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@mui/core-downloads-tracker@5.15.16': {} '@mui/core-downloads-tracker@5.15.16': {}
'@mui/icons-material@5.15.16(@mui/material@5.15.16(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.2.48)(react@18.3.1)': '@mui/icons-material@5.15.16(@mui/material@5.15.16(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.5 '@babel/runtime': 7.24.5
'@mui/material': 5.15.16(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/material': 5.15.16(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1 react: 18.3.1
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@mui/material@5.15.16(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@mui/material@5.15.16(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.5 '@babel/runtime': 7.24.5
'@mui/base': 5.0.0-beta.40(@types/react@18.2.48)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/base': 5.0.0-beta.40(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mui/core-downloads-tracker': 5.15.16 '@mui/core-downloads-tracker': 5.15.16
'@mui/system': 5.15.15(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1) '@mui/system': 5.15.15(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)
'@mui/types': 7.2.14(@types/react@18.2.48) '@mui/types': 7.2.14(@types/react@18.3.3)
'@mui/utils': 5.15.14(@types/react@18.2.48)(react@18.3.1) '@mui/utils': 5.15.14(@types/react@18.3.3)(react@18.3.1)
'@types/react-transition-group': 4.4.10 '@types/react-transition-group': 4.4.10
clsx: 2.1.0 clsx: 2.1.0
csstype: 3.1.3 csstype: 3.1.3
@@ -1538,20 +1542,20 @@ snapshots:
react-is: 18.2.0 react-is: 18.2.0
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
optionalDependencies: optionalDependencies:
'@emotion/react': 11.11.4(@types/react@18.2.48)(react@18.3.1) '@emotion/react': 11.11.4(@types/react@18.3.3)(react@18.3.1)
'@emotion/styled': 11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1) '@emotion/styled': 11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.2.48 '@types/react': 18.3.3
'@mui/private-theming@5.15.14(@types/react@18.2.48)(react@18.3.1)': '@mui/private-theming@5.15.14(@types/react@18.3.3)(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.5 '@babel/runtime': 7.24.5
'@mui/utils': 5.15.14(@types/react@18.2.48)(react@18.3.1) '@mui/utils': 5.15.14(@types/react@18.3.3)(react@18.3.1)
prop-types: 15.8.1 prop-types: 15.8.1
react: 18.3.1 react: 18.3.1
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@mui/styled-engine@5.15.14(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(react@18.3.1)': '@mui/styled-engine@5.15.14(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.5 '@babel/runtime': 7.24.5
'@emotion/cache': 11.11.0 '@emotion/cache': 11.11.0
@@ -1559,30 +1563,30 @@ snapshots:
prop-types: 15.8.1 prop-types: 15.8.1
react: 18.3.1 react: 18.3.1
optionalDependencies: optionalDependencies:
'@emotion/react': 11.11.4(@types/react@18.2.48)(react@18.3.1) '@emotion/react': 11.11.4(@types/react@18.3.3)(react@18.3.1)
'@emotion/styled': 11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1) '@emotion/styled': 11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)
'@mui/system@5.15.15(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1)': '@mui/system@5.15.15(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.5 '@babel/runtime': 7.24.5
'@mui/private-theming': 5.15.14(@types/react@18.2.48)(react@18.3.1) '@mui/private-theming': 5.15.14(@types/react@18.3.3)(react@18.3.1)
'@mui/styled-engine': 5.15.14(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1))(react@18.3.1) '@mui/styled-engine': 5.15.14(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1)
'@mui/types': 7.2.14(@types/react@18.2.48) '@mui/types': 7.2.14(@types/react@18.3.3)
'@mui/utils': 5.15.14(@types/react@18.2.48)(react@18.3.1) '@mui/utils': 5.15.14(@types/react@18.3.3)(react@18.3.1)
clsx: 2.1.0 clsx: 2.1.0
csstype: 3.1.3 csstype: 3.1.3
prop-types: 15.8.1 prop-types: 15.8.1
react: 18.3.1 react: 18.3.1
optionalDependencies: optionalDependencies:
'@emotion/react': 11.11.4(@types/react@18.2.48)(react@18.3.1) '@emotion/react': 11.11.4(@types/react@18.3.3)(react@18.3.1)
'@emotion/styled': 11.11.5(@emotion/react@11.11.4(@types/react@18.2.48)(react@18.3.1))(@types/react@18.2.48)(react@18.3.1) '@emotion/styled': 11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.2.48 '@types/react': 18.3.3
'@mui/types@7.2.14(@types/react@18.2.48)': '@mui/types@7.2.14(@types/react@18.3.3)':
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@mui/utils@5.15.14(@types/react@18.2.48)(react@18.3.1)': '@mui/utils@5.15.14(@types/react@18.3.3)(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.5 '@babel/runtime': 7.24.5
'@types/prop-types': 15.7.11 '@types/prop-types': 15.7.11
@@ -1590,11 +1594,11 @@ snapshots:
react: 18.3.1 react: 18.3.1
react-is: 18.2.0 react-is: 18.2.0
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@popperjs/core@2.11.8': {} '@popperjs/core@2.11.8': {}
'@remix-run/router@1.16.0': {} '@remix-run/router@1.16.1': {}
'@rollup/pluginutils@5.1.0(rollup@4.13.0)': '@rollup/pluginutils@5.1.0(rollup@4.13.0)':
dependencies: dependencies:
@@ -1643,61 +1647,63 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.13.0': '@rollup/rollup-win32-x64-msvc@4.13.0':
optional: true optional: true
'@swc/core-darwin-arm64@1.4.8': '@swc/core-darwin-arm64@1.5.28':
optional: true optional: true
'@swc/core-darwin-x64@1.4.8': '@swc/core-darwin-x64@1.5.28':
optional: true optional: true
'@swc/core-linux-arm-gnueabihf@1.4.8': '@swc/core-linux-arm-gnueabihf@1.5.28':
optional: true optional: true
'@swc/core-linux-arm64-gnu@1.4.8': '@swc/core-linux-arm64-gnu@1.5.28':
optional: true optional: true
'@swc/core-linux-arm64-musl@1.4.8': '@swc/core-linux-arm64-musl@1.5.28':
optional: true optional: true
'@swc/core-linux-x64-gnu@1.4.8': '@swc/core-linux-x64-gnu@1.5.28':
optional: true optional: true
'@swc/core-linux-x64-musl@1.4.8': '@swc/core-linux-x64-musl@1.5.28':
optional: true optional: true
'@swc/core-win32-arm64-msvc@1.4.8': '@swc/core-win32-arm64-msvc@1.5.28':
optional: true optional: true
'@swc/core-win32-ia32-msvc@1.4.8': '@swc/core-win32-ia32-msvc@1.5.28':
optional: true optional: true
'@swc/core-win32-x64-msvc@1.4.8': '@swc/core-win32-x64-msvc@1.5.28':
optional: true optional: true
'@swc/core@1.4.8': '@swc/core@1.5.28':
dependencies: dependencies:
'@swc/counter': 0.1.2 '@swc/counter': 0.1.3
'@swc/types': 0.1.5 '@swc/types': 0.1.8
optionalDependencies: optionalDependencies:
'@swc/core-darwin-arm64': 1.4.8 '@swc/core-darwin-arm64': 1.5.28
'@swc/core-darwin-x64': 1.4.8 '@swc/core-darwin-x64': 1.5.28
'@swc/core-linux-arm-gnueabihf': 1.4.8 '@swc/core-linux-arm-gnueabihf': 1.5.28
'@swc/core-linux-arm64-gnu': 1.4.8 '@swc/core-linux-arm64-gnu': 1.5.28
'@swc/core-linux-arm64-musl': 1.4.8 '@swc/core-linux-arm64-musl': 1.5.28
'@swc/core-linux-x64-gnu': 1.4.8 '@swc/core-linux-x64-gnu': 1.5.28
'@swc/core-linux-x64-musl': 1.4.8 '@swc/core-linux-x64-musl': 1.5.28
'@swc/core-win32-arm64-msvc': 1.4.8 '@swc/core-win32-arm64-msvc': 1.5.28
'@swc/core-win32-ia32-msvc': 1.4.8 '@swc/core-win32-ia32-msvc': 1.5.28
'@swc/core-win32-x64-msvc': 1.4.8 '@swc/core-win32-x64-msvc': 1.5.28
'@swc/counter@0.1.2': {} '@swc/counter@0.1.3': {}
'@swc/types@0.1.5': {} '@swc/types@0.1.8':
dependencies:
'@swc/counter': 0.1.3
'@types/estree@1.0.5': {} '@types/estree@1.0.5': {}
'@types/history@4.7.11': {} '@types/history@4.7.11': {}
'@types/node@20.11.4': '@types/node@20.14.2':
dependencies: dependencies:
undici-types: 5.26.5 undici-types: 5.26.5
@@ -1705,43 +1711,38 @@ snapshots:
'@types/prop-types@15.7.11': {} '@types/prop-types@15.7.11': {}
'@types/prop-types@15.7.5': {}
'@types/react-dom@18.2.18': '@types/react-dom@18.2.18':
dependencies: dependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@types/react-helmet@6.1.11': '@types/react-helmet@6.1.11':
dependencies: dependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@types/react-router-dom@5.3.3': '@types/react-router-dom@5.3.3':
dependencies: dependencies:
'@types/history': 4.7.11 '@types/history': 4.7.11
'@types/react': 18.2.48 '@types/react': 18.3.3
'@types/react-router': 5.1.20 '@types/react-router': 5.1.20
'@types/react-router@5.1.20': '@types/react-router@5.1.20':
dependencies: dependencies:
'@types/history': 4.7.11 '@types/history': 4.7.11
'@types/react': 18.2.48 '@types/react': 18.3.3
'@types/react-transition-group@4.4.10': '@types/react-transition-group@4.4.10':
dependencies: dependencies:
'@types/react': 18.2.48 '@types/react': 18.3.3
'@types/react@18.2.48': '@types/react@18.3.3':
dependencies: dependencies:
'@types/prop-types': 15.7.5 '@types/prop-types': 15.7.11
'@types/scheduler': 0.16.3 csstype: 3.1.3
csstype: 3.1.2
'@types/scheduler@0.16.3': {} '@vitejs/plugin-react-swc@3.7.0(vite@5.2.11(@types/node@20.14.2))':
'@vitejs/plugin-react-swc@3.6.0(vite@5.2.11(@types/node@20.11.4))':
dependencies: dependencies:
'@swc/core': 1.4.8 '@swc/core': 1.5.28
vite: 5.2.11(@types/node@20.11.4) vite: 5.2.11(@types/node@20.14.2)
transitivePeerDependencies: transitivePeerDependencies:
- '@swc/helpers' - '@swc/helpers'
@@ -1952,7 +1953,7 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
million@3.0.6(rollup@4.13.0): million@3.1.11(rollup@4.13.0):
dependencies: dependencies:
'@babel/core': 7.23.7 '@babel/core': 7.23.7
'@babel/types': 7.23.6 '@babel/types': 7.23.6
@@ -2015,16 +2016,16 @@ snapshots:
react-is@18.2.0: {} react-is@18.2.0: {}
react-router-dom@6.23.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): react-router-dom@6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@remix-run/router': 1.16.0 '@remix-run/router': 1.16.1
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-router: 6.23.0(react@18.3.1) react-router: 6.23.1(react@18.3.1)
react-router@6.23.0(react@18.3.1): react-router@6.23.1(react@18.3.1):
dependencies: dependencies:
'@remix-run/router': 1.16.0 '@remix-run/router': 1.16.1
react: 18.3.1 react: 18.3.1
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -2036,6 +2037,11 @@ snapshots:
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-virtuoso@4.7.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react@18.3.1: react@18.3.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -2112,7 +2118,7 @@ snapshots:
tslib@2.5.2: {} tslib@2.5.2: {}
typescript@5.4.3: {} typescript@5.4.5: {}
undici-types@5.26.5: {} undici-types@5.26.5: {}
@@ -2131,13 +2137,13 @@ snapshots:
escalade: 3.1.1 escalade: 3.1.1
picocolors: 1.0.0 picocolors: 1.0.0
vite@5.2.11(@types/node@20.11.4): vite@5.2.11(@types/node@20.14.2):
dependencies: dependencies:
esbuild: 0.20.2 esbuild: 0.20.2
postcss: 8.4.38 postcss: 8.4.38
rollup: 4.13.0 rollup: 4.13.0
optionalDependencies: optionalDependencies:
'@types/node': 20.11.4 '@types/node': 20.14.2
fsevents: 2.3.3 fsevents: 2.3.3
webpack-sources@3.2.3: {} webpack-sources@3.2.3: {}

View File

@@ -2,6 +2,7 @@ import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive' import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard' import Dashboard from '@mui/icons-material/Dashboard'
import LiveTvIcon from '@mui/icons-material/LiveTv'
import Menu from '@mui/icons-material/Menu' import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings' import SettingsIcon from '@mui/icons-material/Settings'
import TerminalIcon from '@mui/icons-material/Terminal' import TerminalIcon from '@mui/icons-material/Terminal'
@@ -121,6 +122,19 @@ export default function Layout() {
<ListItemText primary={i18n.t('archiveButtonLabel')} /> <ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton> </ListItemButton>
</Link> </Link>
<Link to={'/monitor'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<LiveTvIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/log'} style={ <Link to={'/log'} style={
{ {
textDecoration: 'none', textDecoration: 'none',

View File

@@ -24,9 +24,9 @@ languages:
overridesAnchor: Overrides overridesAnchor: Overrides
pathOverrideOption: Enable output path overriding pathOverrideOption: Enable output path overriding
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: Enable output file name overriding
customFilename: Custom filemame (leave blank to use default) customFilename: Custom filename (leave blank to use default)
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsibilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server rpcConnErr: Error while conencting to RPC server
splashText: No active downloads splashText: No active downloads
@@ -51,6 +51,19 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
rpcPollingTimeTitle: RPC polling time
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
templatesReloadInfo: To register a new template it might need a page reload.
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
german: german:
urlInput: Video URL urlInput: Video URL
statusTitle: Status statusTitle: Status
@@ -75,7 +88,7 @@ languages:
overridesAnchor: Überschreibungen overridesAnchor: Überschreibungen
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
customFilename: Custom filemame (leave blank to use default) customFilename: Custom filename (leave blank to use default)
customPath: Benutzerdefinierter Pfad customPath: Benutzerdefinierter Pfad
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung) customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
customArgsInput: Benutzerdefinierte yt-dlp Argumente customArgsInput: Benutzerdefinierte yt-dlp Argumente
@@ -101,6 +114,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
french: french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut statusTitle: Statut
@@ -153,6 +176,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
italian: italian:
urlInput: URL Video (uno per linea) urlInput: URL Video (uno per linea)
statusTitle: Stato statusTitle: Stato
@@ -176,7 +209,7 @@ languages:
overridesAnchor: Sovrascritture overridesAnchor: Sovrascritture
pathOverrideOption: Abilita sovrascrittura percorso di output pathOverrideOption: Abilita sovrascrittura percorso di output
filenameOverrideOption: Abilita sovrascrittura del nome del file di output filenameOverrideOption: Abilita sovrascrittura del nome del file di output
customFilename: Custom filemame (leave blank to use default) customFilename: Custom filename (leave blank to use default)
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
@@ -202,6 +235,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
chinese: chinese:
urlInput: 视频 URL urlInput: 视频 URL
statusTitle: 状态 statusTitle: 状态
@@ -252,6 +295,16 @@ languages:
logsTitle: '日志' logsTitle: '日志'
awaitingLogs: '正在等待日志…' awaitingLogs: '正在等待日志…'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -300,6 +353,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
russian: russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус statusTitle: Статус
@@ -331,23 +394,33 @@ languages:
splashText: Нет активных загрузок splashText: Нет активных загрузок
archiveTitle: Архив archiveTitle: Архив
clipboardAction: URL скопирован в буфер обмена clipboardAction: URL скопирован в буфер обмена
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Находится за обратным прокси
newDownloadButton: New download newDownloadButton: Новая загрузка
homeButtonLabel: Home homeButtonLabel: Home
archiveButtonLabel: Archive archiveButtonLabel: Архив
settingsButtonLabel: Settings settingsButtonLabel: Настройки
rpcAuthenticationLabel: RPC authentication rpcAuthenticationLabel: RPC-аутентификация
themeTogglerLabel: Theme toggler themeTogglerLabel: Переключить тему
loadingLabel: Loading... loadingLabel: Загрузка...
appTitle: App title appTitle: Название приложения
savedTemplates: Saved templates savedTemplates: Сохраненные шаблоны
templatesEditor: Templates editor templatesEditor: Редактор шаблонов
templatesEditorNameLabel: Template name templatesEditorNameLabel: Имя шаблона
templatesEditorContentLabel: Template content templatesEditorContentLabel: Содержание шаблона
logsTitle: 'Logs' logsTitle: 'Логи'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Ожидание логов...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Скачать файлы в zip архиве'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -371,7 +444,7 @@ languages:
overridesAnchor: Overrides overridesAnchor: Overrides
pathOverrideOption: Enable output path overriding pathOverrideOption: Enable output path overriding
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: Enable output file name overriding
customFilename: Custom filemame (leave blank to use default) customFilename: Custom filename (leave blank to use default)
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
@@ -396,6 +469,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -445,6 +528,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
catalan: catalan:
urlInput: URL de YouTube o d'un altre servei compatible urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat statusTitle: Estat
@@ -493,6 +586,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
ukrainian: ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус statusTitle: Статус
@@ -541,6 +644,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
polish: polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status statusTitle: Status
@@ -589,3 +702,77 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
swedish:
urlInput: Videolänk (en per rad)
statusTitle: Status
statusReady: Redo
selectFormatButton: Välj format
startButton: Start
abortAllButton: Avbryt alla
updateBinButton: Uppdatera yt-dlp
darkThemeButton: Mörkt tema
lightThemeButton: Ljust tema
settingsAnchor: Inställningar
serverAddressTitle: Serveraddress
serverPortTitle: Port
extractAudioCheckbox: Extrahera ljud
noMTimeCheckbox: Lägg inte till info om när filen senast modifierades
bgReminder: När du stänger denna sida så kommer nedladdningen att fortsätta i bakgrunden.
toastConnected: 'Ansluten till '
toastUpdated: Uppdaterade yt-dlp!
formatSelectionEnabler: Tillåt val av ljud- och bildformat
themeSelect: 'Tema'
languageSelect: 'Språk'
overridesAnchor: Överskrivningar
pathOverrideOption: Tillåt överskrivning av filsökvägen
filenameOverrideOption: Tillåt överskrivning av filnamn
customFilename: Eget filnamn (lämna blankt för standardnamn)
customPath: Egen filsökväg
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
customArgsInput: Egna yt-dlp-argument
rpcConnErr: Ett fel inträffade vid anslutning till RPC-server
splashText: Inga pågående nedladdningar
archiveTitle: Arkiv
clipboardAction: Kopierade länken
playlistCheckbox: Ladda ner spellista (detta kommer ta did, efter start så kan du stänga detta fönster)
restartAppMessage: En sidomladdning behövs innan förändringen får effekt
servedFromReverseProxyCheckbox: Servern befinner sig bakom en omvänd proxy
urlBase: "URL-bas, måste anges när en omvänd proxy används. Standardinställning: lämna blank"
newDownloadButton: Ny nedladdning
homeButtonLabel: Hem
archiveButtonLabel: Arkiv
settingsButtonLabel: Inställningar
rpcAuthenticationLabel: RPC-Autentisering
themeTogglerLabel: Tema-knapp
loadingLabel: Laddar...
appTitle: Apptitel
savedTemplates: Sparade mallar
templatesEditor: Mallredigerare
templatesEditorNameLabel: Namn
templatesEditorContentLabel: Innehåll
logsTitle: 'Loggar'
awaitingLogs: 'Väntar på loggar...'
bulkDownload: 'Ladda ner filer i ett zip-arkiv'
rpcPollingTimeTitle: Frekvens av RPC-uppdateringar
rpcPollingTimeDescription: En högre frekvens kräver mer CPU-resurser för både server och klient
templatesReloadInfo: För att registrera en ny mall så kan en sidomladdning krävas.
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!

View File

@@ -1,4 +1,4 @@
import { selector } from 'recoil' import { atom, selector } from 'recoil'
import { RPCClient } from '../lib/rpcClient' import { RPCClient } from '../lib/rpcClient'
import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings' import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings'
@@ -12,3 +12,12 @@ export const rpcClientState = selector({
), ),
dangerouslyAllowMutability: true, 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

@@ -64,8 +64,7 @@ export const serverAddressState = atom<string>({
export const serverPortState = atom<number>({ export const serverPortState = atom<number>({
key: 'serverPortState', key: 'serverPortState',
default: Number(localStorage.getItem('server-port')) || default: Number(localStorage.getItem('server-port')) || Number(window.location.port),
Number(window.location.port),
effects: [ effects: [
({ onSet }) => ({ onSet }) =>
onSet(a => localStorage.setItem('server-port', a.toString())) onSet(a => localStorage.setItem('server-port', a.toString()))
@@ -197,7 +196,7 @@ export const cookiesState = atom({
] ]
}) })
export const themeSelector = selector<ThemeNarrowed>({ const themeSelector = selector<ThemeNarrowed>({
key: 'themeSelector', key: 'themeSelector',
get: ({ get }) => { get: ({ get }) => {
const theme = get(themeState) const theme = get(themeState)

View File

@@ -6,16 +6,6 @@ export const connectedState = atom({
default: false default: false
}) })
export const updatedBinaryState = atom({
key: 'updatedBinaryState',
default: false
})
export const isDownloadingState = atom({
key: 'isDownloadingState',
default: false
})
export const freeSpaceBytesState = selector({ export const freeSpaceBytesState = selector({
key: 'freeSpaceBytesState', key: 'freeSpaceBytesState',
get: async ({ get }) => { get: async ({ get }) => {

View File

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

View File

@@ -26,19 +26,17 @@ import {
Suspense, Suspense,
forwardRef, forwardRef,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
useTransition useTransition
} from 'react' } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate' import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate'
import { latestCliArgumentsState, settingsState } from '../atoms/settings' import { settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status' import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser'
import type { DLMetadata } from '../types' import type { DLMetadata } from '../types'
import { toFormatArgs } from '../utils' import { toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions' import ExtraDownloadOptions from './ExtraDownloadOptions'
@@ -71,7 +69,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const [pickedBestFormat, setPickedBestFormat] = useState('') const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useRecoilState(customArgsState) const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [downloadPath, setDownloadPath] = useState('') const [downloadPath, setDownloadPath] = useState('')
@@ -83,10 +80,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const [isPlaylist, setIsPlaylist] = useState(false) const [isPlaylist, setIsPlaylist] = useState(false)
const argsBuilder = useMemo(() =>
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
@@ -110,9 +103,9 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
if (pickedBestFormat !== '') codes.push(pickedBestFormat) if (pickedBestFormat !== '') codes.push(pickedBestFormat)
await new Promise(r => setTimeout(r, 10)) await new Promise(r => setTimeout(r, 10))
await client.download({ client.download({
url: immediate || line, url: immediate || line,
args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`, args: `${toFormatArgs(codes)} ${downloadTemplate}`,
pathOverride: downloadPath ?? '', pathOverride: downloadPath ?? '',
renameTo: settings.fileRenaming ? filenameTemplate : '', renameTo: settings.fileRenaming ? filenameTemplate : '',
playlist: isPlaylist, playlist: isPlaylist,
@@ -122,7 +115,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
resetInput() resetInput()
setDownloadFormats(undefined) setDownloadFormats(undefined)
onDownloadStart(immediate || line) onDownloadStart(immediate || line)
}, 250) }, 100)
} }
setUrl('') setUrl('')
@@ -132,7 +125,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
* Retrive url from input and display the formats selection view * Retrive url from input and display the formats selection view
*/ */
const sendUrlFormatSelection = () => { const sendUrlFormatSelection = () => {
setUrl('')
setPickedAudioFormat('') setPickedAudioFormat('')
setPickedVideoFormat('') setPickedVideoFormat('')
setPickedBestFormat('') setPickedBestFormat('')
@@ -327,19 +319,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
label={i18n.t('playlistCheckbox')} label={i18n.t('playlistCheckbox')}
/> />
</Grid> </Grid>
<Grid item>
<FormControlLabel
control={
<Checkbox
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
/>
}
checked={argsBuilder.extractAudio}
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
disabled={settings.formatSelection}
label={i18n.t('extractAudioCheckbox')}
/>
</Grid>
</Grid> </Grid>
<Grid item> <Grid item>
<Button <Button

View File

@@ -3,7 +3,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'
import { loadingDownloadsState } from '../atoms/downloads' import { loadingDownloadsState } from '../atoms/downloads'
import { listViewState } from '../atoms/settings' import { listViewState } from '../atoms/settings'
import { loadingAtom } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
import DownloadsCardView from './DownloadsCardView' import DownloadsGridView from './DownloadsGridView'
import DownloadsTableView from './DownloadsTableView' import DownloadsTableView from './DownloadsTableView'
const Downloads: React.FC = () => { const Downloads: React.FC = () => {
@@ -21,7 +21,7 @@ const Downloads: React.FC = () => {
if (tableView) return <DownloadsTableView /> if (tableView) return <DownloadsTableView />
return <DownloadsCardView /> return <DownloadsGridView />
} }
export default Downloads export default Downloads

View File

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

View File

@@ -18,12 +18,60 @@ import {
TableRow, TableRow,
Typography Typography
} from "@mui/material" } from "@mui/material"
import { forwardRef } from 'react'
import { TableComponents, TableVirtuoso } from 'react-virtuoso'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads' import { activeDownloadsState } from '../atoms/downloads'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types'
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils" 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 }) { function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) {
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center' }}> <Box sx={{ display: 'flex', alignItems: 'center' }}>
@@ -39,14 +87,42 @@ function LinearProgressWithLabel(props: LinearProgressProps & { value: number })
) )
} }
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 DownloadsTableView: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const downloads = useRecoilValue(activeDownloadsState) const downloads = useRecoilValue(activeDownloadsState)
const serverAddr = useRecoilValue(serverURL)
const { client } = useRPC() const { client } = useRPC()
const abort = (id: string) => client.kill(id)
const viewFile = (path: string) => { const viewFile = (path: string) => {
const encoded = base64URLEncode(path) const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`) window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
@@ -57,41 +133,14 @@ const DownloadsTableView: React.FC = () => {
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`) 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 ( return (
<TableContainer <>
sx={{ minHeight: '80vh', mt: 4 }}
hidden={downloads.length === 0}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={8}>
<Typography fontWeight={500} fontSize={13}>Status</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={13}>Title</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight={500} fontSize={13}>Speed</Typography>
</TableCell>
<TableCell align="center" width={200}>
<Typography fontWeight={500} fontSize={13}>Progress</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight={500} fontSize={13}>Size</Typography>
</TableCell>
<TableCell align="right" width={180}>
<Typography fontWeight={500} fontSize={13}>Added on</Typography>
</TableCell>
<TableCell align="right" width={8}>
<Typography fontWeight={500} fontSize={13}>Actions</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{
downloads.map(download => (
<TableRow key={download.id}>
<TableCell> <TableCell>
{download.progress.percentage === '-1' {download.progress.percentage === '-1'
? <DownloadDoneIcon color="primary" /> ? <DownloadDoneIcon color="primary" />
@@ -124,7 +173,7 @@ const DownloadsTableView: React.FC = () => {
<ButtonGroup> <ButtonGroup>
<IconButton <IconButton
size="small" size="small"
onClick={() => abort(download.id)} onClick={() => stop(download)}
> >
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />} {download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}
@@ -147,12 +196,20 @@ const DownloadsTableView: React.FC = () => {
} }
</ButtonGroup> </ButtonGroup>
</TableCell> </TableCell>
</TableRow> </>
)) )
} }
</TableBody>
</Table> return (
</TableContainer> <Box style={{ height: downloads.length === 0 ? '0vh' : '80vh', width: '100%' }}>
<TableVirtuoso
hidden={downloads.length === 0}
data={downloads}
components={VirtuosoTableComponents}
fixedHeaderContent={fixedHeaderContent}
itemContent={rowContent}
/>
</Box>
) )
} }

View File

@@ -15,6 +15,12 @@ const ExtraDownloadOptions: React.FC = () => {
disablePortal disablePortal
options={customTemplates.map(({ name, content }) => ({ label: name, content }))} options={customTemplates.map(({ name, content }) => ({ label: name, content }))}
autoHighlight autoHighlight
defaultValue={
customTemplates
.filter(({ id, name }) => id === "0" || name === "default")
.map(({ name, content }) => ({ label: name, content }))
.at(0)
}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
onChange={(_, value) => { onChange={(_, value) => {
setCustomArgs(value?.content!) setCustomArgs(value?.content!)

View File

@@ -1,15 +1,15 @@
import DownloadIcon from '@mui/icons-material/Download'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet' import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import { AppBar, Chip, Divider, Toolbar } from '@mui/material' import { AppBar, Chip, Divider, Toolbar } from '@mui/material'
import { Suspense } from 'react' import { Suspense } from 'react'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { settingsState } from '../atoms/settings' import { settingsState } from '../atoms/settings'
import { connectedState } from '../atoms/status' import { connectedState } from '../atoms/status'
import { totalDownloadSpeedState } from '../atoms/ui'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { formatSpeedMiB } from '../utils'
import FreeSpaceIndicator from './FreeSpaceIndicator' import FreeSpaceIndicator from './FreeSpaceIndicator'
import VersionIndicator from './VersionIndicator' import VersionIndicator from './VersionIndicator'
import DownloadIcon from '@mui/icons-material/Download'
import { totalDownloadSpeedState } from '../atoms/ui'
import { formatSpeedMiB } from '../utils'
const Footer: React.FC = () => { const Footer: React.FC = () => {
const settings = useRecoilValue(settingsState) const settings = useRecoilValue(settingsState)
@@ -35,7 +35,8 @@ const Footer: React.FC = () => {
display: 'flex', gap: 1, justifyContent: 'space-between' display: 'flex', gap: 1, justifyContent: 'space-between'
}}> }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<Chip label="RPC v3.0.8" variant="outlined" size="small" /> {/* TODO: make it dynamic */}
<Chip label="RPC v3.2.0" variant="outlined" size="small" />
<VersionIndicator /> <VersionIndicator />
</div> </div>
<div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}> <div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}>

View File

@@ -1,9 +1,9 @@
import AddCircleIcon from '@mui/icons-material/AddCircle' import AddCircleIcon from '@mui/icons-material/AddCircle'
import BuildCircleIcon from '@mui/icons-material/BuildCircle' import BuildCircleIcon from '@mui/icons-material/BuildCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever' import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FolderZipIcon from '@mui/icons-material/FolderZip'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import ViewAgendaIcon from '@mui/icons-material/ViewAgenda' import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'
import FolderZipIcon from '@mui/icons-material/FolderZip'
import { import {
SpeedDial, SpeedDial,
SpeedDialAction, SpeedDialAction,
@@ -26,8 +26,6 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
const abort = () => client.killAll()
return ( return (
<SpeedDial <SpeedDial
ariaLabel="Home speed dial" ariaLabel="Home speed dial"
@@ -42,12 +40,12 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
<SpeedDialAction <SpeedDialAction
icon={<FolderZipIcon />} icon={<FolderZipIcon />}
tooltipTitle={i18n.t('bulkDownload')} tooltipTitle={i18n.t('bulkDownload')}
onClick={() => window.open(`${serverAddr}/archive/bulk`)} onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
/> />
<SpeedDialAction <SpeedDialAction
icon={<DeleteForeverIcon />} icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')} tooltipTitle={i18n.t('abortAllButton')}
onClick={abort} onClick={() => client.killAll()}
/> />
<SpeedDialAction <SpeedDialAction
icon={<BuildCircleIcon />} icon={<BuildCircleIcon />}

View File

@@ -1,10 +1,10 @@
import { Backdrop, CircularProgress } from '@mui/material' import { Backdrop, CircularProgress } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
const LoadingBackdrop: React.FC = () => { type Props = {
const isLoading = useRecoilValue(loadingAtom) isLoading: boolean
}
const LoadingBackdrop: React.FC<Props> = ({ isLoading }) => {
return ( return (
<Backdrop <Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }} sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}

View File

@@ -1,13 +1,10 @@
import LogoutIcon from '@mui/icons-material/Logout' import LogoutIcon from '@mui/icons-material/Logout'
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
export default function Logout() { export default function Logout() {
const navigate = useNavigate() const navigate = useNavigate()
const url = useRecoilValue(serverURL)
const logout = async () => { const logout = async () => {
localStorage.removeItem('token') localStorage.removeItem('token')

View File

@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { take, timer } from 'rxjs' import { take, timer } from 'rxjs'
import { downloadsState } from '../atoms/downloads' import { downloadsState } from '../atoms/downloads'
import { rpcPollingTimeState } from '../atoms/rpc'
import { serverAddressAndPortState } from '../atoms/settings' import { serverAddressAndPortState } from '../atoms/settings'
import { connectedState } from '../atoms/status' import { connectedState } from '../atoms/status'
import { useSubscription } from '../hooks/observable' import { useSubscription } from '../hooks/observable'
@@ -19,6 +20,7 @@ const SocketSubscriber: React.FC<Props> = () => {
const [, setDownloads] = useRecoilState(downloadsState) const [, setDownloads] = useRecoilState(downloadsState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState) const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
const rpcPollingTime = useRecoilValue(rpcPollingTimeState)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
@@ -70,11 +72,10 @@ const SocketSubscriber: React.FC<Props> = () => {
useEffect(() => { useEffect(() => {
if (connected) { if (connected) {
const sub = timer(0, 1000).subscribe(() => client.running()) const sub = timer(0, rpcPollingTime).subscribe(() => client.running())
return () => sub.unsubscribe() return () => sub.unsubscribe()
} }
}, [connected, client]) }, [connected, client, rpcPollingTime])
return null return null
} }

View File

@@ -2,6 +2,7 @@ import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import { import {
Alert,
AppBar, AppBar,
Backdrop, Backdrop,
Box, Box,
@@ -145,7 +146,12 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
backgroundColor: (theme) => theme.palette.background.default, backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)` minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}> }}>
<Grid container spacing={2} sx={{ p: 4 }}> <Grid container spacing={2} sx={{ px: 4, pt: 3, pb: 4 }}>
<Grid item>
<Alert severity="info">
{i18n.t('templatesReloadInfo')}
</Alert>
</Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Paper <Paper
elevation={4} elevation={4}

View File

@@ -0,0 +1,125 @@
import CloseIcon from '@mui/icons-material/Close'
import {
Alert,
AppBar,
Box,
Button,
Container,
Dialog,
Grid,
IconButton,
Paper,
Slide,
TextField,
Toolbar,
Typography
} from '@mui/material'
import { TransitionProps } from '@mui/material/transitions'
import { forwardRef, useState } from 'react'
import { useToast } from '../../hooks/toast'
import { useI18n } from '../../hooks/useI18n'
import { useRPC } from '../../hooks/useRPC'
type Props = {
open: boolean
onClose: () => void
}
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />
})
const LivestreamDialog: React.FC<Props> = ({ open, onClose }) => {
const [livestreamURL, setLivestreamURL] = useState('')
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const exec = (url: string) => client.execLivestream(url)
return (
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<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">
Livestream monitor
</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',
flexDirection: 'column',
}}
>
<Grid container>
<Grid item xs={12} mb={2}>
<Alert severity="info">
{i18n.t('livestreamDownloadInfo')}
</Alert>
<Alert severity="warning" sx={{ mt: 1 }}>
{i18n.t('livestreamExperimentalWarning')}
</Alert>
</Grid>
<Grid item xs={12}>
<TextField
multiline
fullWidth
label={i18n.t('livestreamURLInput')}
variant="outlined"
onChange={(e) => setLivestreamURL(e.target.value)}
/>
</Grid>
<Grid item>
<Button
sx={{ mt: 2 }}
variant="contained"
disabled={livestreamURL === ''}
onClick={() => {
exec(livestreamURL)
onClose()
pushMessage(`Monitoring ${livestreamURL}`, 'info')
}}
>
{i18n.t('startButton')}
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Container>
</Box>
</Dialog>
)
}
export default LivestreamDialog

View File

@@ -0,0 +1,34 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material'
import { useI18n } from '../../hooks/useI18n'
type Props = {
onOpen: () => void
onStopAll: () => void
}
const LivestreamSpeedDial: React.FC<Props> = ({ onOpen, onStopAll }) => {
const { i18n } = useI18n()
return (
<SpeedDial
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={onStopAll}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={i18n.t('newDownloadButton')}
onClick={onOpen}
/>
</SpeedDial>
)
}
export default LivestreamSpeedDial

View File

@@ -0,0 +1,38 @@
import LiveTvIcon from '@mui/icons-material/LiveTv'
import { Container, SvgIcon, Typography, styled } from '@mui/material'
import { useI18n } from '../../hooks/useI18n'
const FlexContainer = styled(Container)({
display: 'flex',
minWidth: '100%',
minHeight: '80vh',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
})
const Title = styled(Typography)({
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: '0.5rem'
})
export default function NoLivestreams() {
const { i18n } = useI18n()
return (
<FlexContainer>
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
<SvgIcon sx={{ fontSize: '200px' }}>
<LiveTvIcon />
</SvgIcon>
</Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
No livestreams monitored
</Title>
</FlexContainer>
)
}

View File

@@ -1,66 +0,0 @@
export class CliArguments {
private _extractAudio: boolean
private _noMTime: boolean
constructor(extractAudio = false, noMTime = true) {
this._extractAudio = extractAudio
this._noMTime = noMTime
}
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()
}
private reset() {
this._extractAudio = false
this._noMTime = false
}
public fromString(str: string): CliArguments {
this.reset()
if (str) {
if (str.includes('-x')) {
this._extractAudio = true
}
if (str.includes('--no-mtime')) {
this._noMTime = true
}
}
return this
}
}

View File

@@ -1,5 +1,5 @@
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types' import type { DLMetadata, LiveStreamProgress, RPCRequest, RPCResponse, RPCResult } from '../types'
import { WebSocketSubject, webSocket } from 'rxjs/webSocket' import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
@@ -132,6 +132,13 @@ export class RPCClient {
}) })
} }
public clear(id: string) {
this.sendHTTP({
method: 'Service.Clear',
params: [id],
})
}
public killAll() { public killAll() {
this.sendHTTP({ this.sendHTTP({
method: 'Service.KillAll', method: 'Service.KillAll',
@@ -153,9 +160,32 @@ export class RPCClient {
}) })
} }
public updateExecutable() { public execLivestream(url: string) {
return this.sendHTTP({ return this.sendHTTP({
method: 'Service.UpdateExecutable', method: 'Service.ExecLivestream',
params: [{
URL: url
}]
})
}
public progressLivestream() {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.ProgressLivestream',
params: []
})
}
public killLivestream(url: string) {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.KillLivestream',
params: [url]
})
}
public killAllLivestream() {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.KillAllLivestream',
params: [] params: []
}) })
} }

View File

@@ -8,6 +8,7 @@ const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login')) const Login = lazy(() => import('./views/Login'))
const Archive = lazy(() => import('./views/Archive')) const Archive = lazy(() => import('./views/Archive'))
const Settings = lazy(() => import('./views/Settings')) const Settings = lazy(() => import('./views/Settings'))
const LiveStream = lazy(() => import('./views/Livestream'))
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary')) const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
@@ -74,6 +75,14 @@ export const router = createHashRouter([
</Suspense > </Suspense >
) )
}, },
{
path: '/monitor',
element: (
<Suspense fallback={<CircularProgress />}>
<LiveStream />
</Suspense >
)
},
] ]
}, },
]) ])

View File

@@ -9,6 +9,10 @@ export type RPCMethods =
| "Service.ExecPlaylist" | "Service.ExecPlaylist"
| "Service.DirectoryTree" | "Service.DirectoryTree"
| "Service.UpdateExecutable" | "Service.UpdateExecutable"
| "Service.ExecLivestream"
| "Service.ProgressLivestream"
| "Service.KillLivestream"
| "Service.KillAllLivestream"
export type RPCRequest = { export type RPCRequest = {
method: RPCMethods method: RPCMethods
@@ -34,11 +38,18 @@ type DownloadInfo = {
created_at: string created_at: string
} }
export enum ProcessStatus {
Pending = 0,
Downloading,
Completed,
Errored,
}
type DownloadProgress = { type DownloadProgress = {
speed: number speed: number
eta: number eta: number
percentage: string percentage: string
process_status: number process_status: ProcessStatus
} }
export type RPCResult = Readonly<{ export type RPCResult = Readonly<{
@@ -76,18 +87,30 @@ export type DirectoryEntry = {
name: string name: string
path: string path: string
size: number size: number
shaSum: string
modTime: string modTime: string
isVideo: boolean isVideo: boolean
isDirectory: 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 = { export type CustomTemplate = {
id: string id: string
name: string name: string
content: string content: string
} }
export enum LiveStreamStatus {
WAITING,
IN_PROGRESS,
COMPLETED,
ERRORED
}
export type LiveStreamProgress = Record<string, {
Status: LiveStreamStatus
WaitTime: string
LiveDate: string
}>

View File

@@ -1,11 +1,7 @@
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import type { RPCResponse } from "./types" 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 { 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 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) return ipRegex.test(ipAddr)
@@ -58,15 +54,15 @@ export function isRPCResponse(object: any): object is RPCResponse<any> {
return 'result' in object && 'id' in object return 'result' in object && 'id' in object
} }
export function mapProcessStatus(status: number) { export function mapProcessStatus(status: ProcessStatus) {
switch (status) { switch (status) {
case 0: case ProcessStatus.Pending:
return 'Pending' return 'Pending'
case 1: case ProcessStatus.Downloading:
return 'Downloading' return 'Downloading'
case 2: case ProcessStatus.Completed:
return 'Completed' return 'Completed'
case 3: case ProcessStatus.Errored:
return 'Error' return 'Error'
default: default:
return 'Pending' return 'Pending'

View File

@@ -113,7 +113,6 @@ export default function Downloaded() {
modTime: '', modTime: '',
name: '..', name: '..',
path: upperLevel, path: upperLevel,
shaSum: '',
size: 0, size: 0,
}, ...r.filter(f => f.name !== '')] }, ...r.filter(f => f.name !== '')]
: r.filter(f => f.name !== '') : r.filter(f => f.name !== '')
@@ -144,7 +143,6 @@ export default function Downloaded() {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
path: entry.path, path: entry.path,
shaSum: entry.shaSum,
}) })
}), }),
matchW( matchW(

View File

@@ -1,15 +1,19 @@
import { import {
Container Container
} from '@mui/material' } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
import Downloads from '../components/Downloads' import Downloads from '../components/Downloads'
import HomeActions from '../components/HomeActions' import HomeActions from '../components/HomeActions'
import LoadingBackdrop from '../components/LoadingBackdrop' import LoadingBackdrop from '../components/LoadingBackdrop'
import Splash from '../components/Splash' import Splash from '../components/Splash'
export default function Home() { export default function Home() {
const isLoading = useRecoilValue(loadingAtom)
return ( return (
<Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}> <Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}>
<LoadingBackdrop /> <LoadingBackdrop isLoading={isLoading} />
<Splash /> <Splash />
<Downloads /> <Downloads />
<HomeActions /> <HomeActions />

View File

@@ -0,0 +1,134 @@
import {
Box,
Button,
Chip,
Container,
Paper,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
} from '@mui/material'
import { useState } from 'react'
import { interval } from 'rxjs'
import LivestreamDialog from '../components/livestream/LivestreamDialog'
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
import NoLivestreams from '../components/livestream/NoLivestreams'
import LoadingBackdrop from '../components/LoadingBackdrop'
import { useSubscription } from '../hooks/observable'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { LiveStreamProgress, LiveStreamStatus } from '../types'
const LiveStreamMonitorView: React.FC = () => {
const { i18n } = useI18n()
const { client } = useRPC()
const [progress, setProgress] = useState<LiveStreamProgress>()
const [openDialog, setOpenDialog] = useState(false)
useSubscription(interval(1000), () => {
client
.progressLivestream()
.then(r => setProgress(r.result))
})
const formatMicro = (microseconds: number) => {
const ms = microseconds / 1_000_000
let s = ms / 1000
const hr = s / 3600
s %= 3600
const mt = s / 60
s %= 60
// huh?
const ss = (Math.abs(s - 1)).toFixed(0).padStart(2, '0')
const mts = mt.toFixed(0).padStart(2, '0')
const hrs = hr.toFixed(0).padStart(2, '0')
return `${hrs}:${mts}:${ss}`
}
const mapStatusToChip = (status: LiveStreamStatus): React.ReactNode => {
switch (status) {
case LiveStreamStatus.WAITING:
return <Chip label='Waiting/Wait start' color='warning' size='small' />
case LiveStreamStatus.IN_PROGRESS:
return <Chip label='Downloading' color='primary' size='small' />
case LiveStreamStatus.COMPLETED:
return <Chip label='Completed' color='success' size='small' />
case LiveStreamStatus.ERRORED:
return <Chip label='Errored' color='error' size='small' />
default:
return <Chip label='Unknown state' color='secondary' size='small' />
}
}
const stopAll = () => client.killAllLivestream()
const stop = (url: string) => client.killLivestream(url)
return (
<>
<LoadingBackdrop isLoading={!progress} />
<LivestreamSpeedDial onOpen={() => setOpenDialog(s => !s)} onStopAll={stopAll} />
<LivestreamDialog open={openDialog} onClose={() => setOpenDialog(s => !s)} />
{!progress || Object.keys(progress).length === 0 ?
<NoLivestreams /> :
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Paper sx={{
p: 2.5,
display: 'flex',
flexDirection: 'column',
minHeight: '80vh',
}}>
<TableContainer component={Box}>
<Table sx={{ minWidth: '100%' }}>
<TableHead>
<TableRow>
<TableCell>{i18n.t('livestreamURLInput')}</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right">Time to live</TableCell>
<TableCell align="right">Starts on</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{progress && Object.keys(progress).map(k => (
<TableRow
key={k}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell>{k}</TableCell>
<TableCell align='right'>
{mapStatusToChip(progress[k].Status)}
</TableCell>
<TableCell align='right'>
{progress[k].Status === LiveStreamStatus.WAITING
? formatMicro(Number(progress[k].WaitTime))
: "-"
}
</TableCell>
<TableCell align='right'>
{progress[k].Status === LiveStreamStatus.WAITING
? new Date(progress[k].LiveDate).toLocaleString()
: "-"
}
</TableCell>
<TableCell align='right'>
<Button variant='contained' size='small' onClick={() => stop(k)}>
Stop
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Container>}
</>
)
}
export default LiveStreamMonitorView

View File

@@ -6,19 +6,20 @@ import styled from '@emotion/styled'
import { import {
Button, Button,
Container, Container,
Divider,
Paper, Paper,
Stack, Stack,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
const LoginContainer = styled(Container)({ const LoginContainer = styled(Container)({
display: 'flex', display: 'flex',
@@ -81,6 +82,8 @@ export default function Login() {
)() )()
} }
const loginWithOpenId = () => window.open(`${url}/auth/openid/login`)
return ( return (
<LoginContainer> <LoginContainer>
<Paper sx={{ padding: '1.5rem', minWidth: '25%' }}> <Paper sx={{ padding: '1.5rem', minWidth: '25%' }}>
@@ -89,12 +92,8 @@ export default function Login() {
yt-dlp WebUI yt-dlp WebUI
</Title> </Title>
<Title fontWeight={'500'} fontSize={16} color={'gray'}> <Title fontWeight={'500'} fontSize={16} color={'gray'}>
Authentication token will expire after 30 days. To configure authentication check the&nbsp;
</Title> <a href='https://github.com/marcopeocchi/yt-dlp-web-ui/wiki/Authentication-methods'>wiki</a>.
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
In order to enable RPC authentication append the --auth,
<br />
--user [username] and --pass [password] flags.
</Title> </Title>
<TextField <TextField
label="Username" label="Username"
@@ -113,6 +112,16 @@ export default function Login() {
<Button variant="contained" size="large" onClick={() => login()}> <Button variant="contained" size="large" onClick={() => login()}>
Submit Submit
</Button> </Button>
<Divider>
<Typography color={'gray'}>
or use your authentication provider
</Typography>
</Divider>
<Button variant="contained" size="large" onClick={loginWithOpenId}>
Login with OpenID
</Button>
</Stack> </Stack>
</Paper> </Paper>
</LoginContainer> </LoginContainer>

View File

@@ -4,7 +4,6 @@ import {
Container, Container,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
FormGroup,
Grid, Grid,
InputAdornment, InputAdornment,
InputLabel, InputLabel,
@@ -12,6 +11,7 @@ import {
Paper, Paper,
Select, Select,
SelectChangeEvent, SelectChangeEvent,
Slider,
Stack, Stack,
Switch, Switch,
TextField, TextField,
@@ -27,6 +27,7 @@ import {
map, map,
takeWhile takeWhile
} from 'rxjs' } from 'rxjs'
import { rpcPollingTimeState } from '../atoms/rpc'
import { import {
Language, Language,
Theme, Theme,
@@ -36,7 +37,6 @@ import {
formatSelectionState, formatSelectionState,
languageState, languageState,
languages, languages,
latestCliArgumentsState,
pathOverridingState, pathOverridingState,
servedFromReverseProxyState, servedFromReverseProxyState,
servedFromReverseProxySubDirState, servedFromReverseProxySubDirState,
@@ -48,22 +48,25 @@ import CookiesTextField from '../components/CookiesTextField'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser'
import { validateDomain, validateIP } from '../utils' import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS // NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() { export default function Settings() {
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState) const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
const [baseURL, setBaseURL] = useRecoilState(servedFromReverseProxySubDirState) const [baseURL, setBaseURL] = useRecoilState(servedFromReverseProxySubDirState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState) const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState) const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState) const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState) const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState)
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState) const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
const [serverPort, setServerPort] = useRecoilState(serverPortState) const [serverPort, setServerPort] = useRecoilState(serverPortState)
const [pollingTime, setPollingTime] = useRecoilState(rpcPollingTimeState)
const [language, setLanguage] = useRecoilState(languageState) const [language, setLanguage] = useRecoilState(languageState)
const [appTitle, setApptitle] = useRecoilState(appTitleState) const [appTitle, setApptitle] = useRecoilState(appTitleState)
const [cliArgs, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [theme, setTheme] = useRecoilState(themeState) const [theme, setTheme] = useRecoilState(themeState)
const [invalidIP, setInvalidIP] = useState(false) const [invalidIP, setInvalidIP] = useState(false)
@@ -73,8 +76,6 @@ export default function Settings() {
const { pushMessage } = useToast() const { pushMessage } = useToast()
const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), [])
const baseURL$ = useMemo(() => new Subject<string>(), []) const baseURL$ = useMemo(() => new Subject<string>(), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), []) const serverPort$ = useMemo(() => new Subject<string>(), [])
@@ -148,8 +149,6 @@ export default function Settings() {
return ( return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}> <Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={12} lg={12}>
<Paper <Paper
sx={{ sx={{
p: 2.5, p: 2.5,
@@ -161,7 +160,6 @@ export default function Settings() {
<Typography pb={2} variant="h6" color="primary"> <Typography pb={2} variant="h6" color="primary">
{i18n.t('settingsAnchor')} {i18n.t('settingsAnchor')}
</Typography> </Typography>
<FormGroup>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={11}> <Grid item xs={12} md={11}>
<TextField <TextField
@@ -195,6 +193,34 @@ export default function Settings() {
error={appTitle === ''} error={appTitle === ''}
/> />
</Grid> </Grid>
<Grid item xs={12} md={12}>
<Typography>
{i18n.t('rpcPollingTimeTitle')}
</Typography>
<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}> <Grid item xs={12}>
<Typography variant="h6" color="primary" sx={{ mb: 0.5 }}> <Typography variant="h6" color="primary" sx={{ mb: 0.5 }}>
Reverse Proxy Reverse Proxy
@@ -228,7 +254,7 @@ export default function Settings() {
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 0.5, mb: 2 }}> <Typography variant="h6" color="primary" sx={{ mt: 0.5, mb: 2 }}>
Appaerance Appearance
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
@@ -265,32 +291,12 @@ export default function Settings() {
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}> <Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
General download settings General download settings
</Typography> </Typography>
<FormControlLabel
control={
<Switch
defaultChecked={argsBuilder.noMTime}
onChange={() => setCliArgs(argsBuilder.toggleNoMTime().toString())}
/>
}
label={i18n.t('noMTimeCheckbox')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={argsBuilder.extractAudio}
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
disabled={formatSelection}
/>
}
label={i18n.t('extractAudioCheckbox')}
/>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={formatSelection} defaultChecked={formatSelection}
onChange={() => { onChange={() => {
setCliArgs(argsBuilder.disableExtractAudio().toString())
setFormatSelection(!formatSelection) setFormatSelection(!formatSelection)
}} }}
/> />
@@ -354,10 +360,7 @@ export default function Settings() {
</Button> </Button>
</Stack> </Stack>
</Grid> </Grid>
</FormGroup>
</Paper> </Paper>
</Grid>
</Grid>
</Container> </Container>
) )
} }

23
go.mod
View File

@@ -4,23 +4,26 @@ go 1.22
require ( require (
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
github.com/go-chi/chi/v5 v5.0.12 github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.3
github.com/reactivex/rxgo/v2 v2.5.0 github.com/reactivex/rxgo/v2 v2.5.0
golang.org/x/sync v0.6.0 golang.org/x/oauth2 v0.21.0
golang.org/x/sys v0.18.0 golang.org/x/sync v0.7.0
golang.org/x/sys v0.22.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.5 modernc.org/sqlite v1.31.1
) )
require ( require (
github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
@@ -29,11 +32,11 @@ require (
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.9.0 // indirect github.com/stretchr/testify v1.9.0 // indirect
github.com/teivah/onecontext v1.3.0 // indirect github.com/teivah/onecontext v1.3.0 // indirect
golang.org/x/net v0.22.0 // indirect golang.org/x/crypto v0.25.0 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e // indirect
modernc.org/libc v1.47.0 // indirect modernc.org/libc v1.55.7 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
) )

70
go.sum
View File

@@ -1,8 +1,10 @@
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM= 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/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -11,18 +13,24 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 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.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-jose/go-jose/v4 v4.0.3 h1:o8aphO8Hv6RPmH+GfzVuyf7YXSBibp+8YyHdOoDESGo=
github.com/go-jose/go-jose/v4 v4.0.3/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 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/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/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 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/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
@@ -32,8 +40,6 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -55,27 +61,31 @@ github.com/teivah/onecontext v1.3.0/go.mod h1:hoW1nmdPVK/0jrvGtcx8sCKYs2PiS4z0zz
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 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/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= 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/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
@@ -83,28 +93,32 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.19.5 h1:QlsZyQ1zf78DGeqnQ9ILi9hXyMdoC5e1qoGNUyBjHQw= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.19.5/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.13.0 h1:99E8QHRoPrXN8VpS0zgAgJ5nSjpXrPKpsJIMvGL/2Oc= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.13.0/go.mod h1:Td6RI9W9G2ZpKHaJ7UeGEiB2aIpoDqLBnm4wtkbJTbQ= modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/ccgo/v4 v4.20.5 h1:s04akhT2dysD0DFOlv9fkQ6oUTLPYgMnnDk9oaqjszM=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 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 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 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/v2 v2.4.3 h1:Ik4ZcMbC7aY4ZDPUhzXVXi7GMub9QcXLTfXn3mWpNw8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e h1:WPC4v0rNIFb2PY+nBBEEKyugPPRHPzUgyN3xZPpGK58=
modernc.org/libc v1.47.0 h1:BXrzId9fOOkBtS+uFQ5aZyVGmt7WcSEPrXF5Kwsho90= modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.47.0/go.mod h1:gzCncw0a74aCiVqHeWAYHHaW//fkSHHS/3S/gfhLlCI= modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/libc v1.55.7 h1:/5PMGAF3tyZhK72WpoqeLNtgUUpYMrnhT+Gm/5tVDgs=
modernc.org/libc v1.55.7/go.mod h1:JXguUpMkbw1gknxspNE9XaG+kk9hDAAnBxpA6KGLiyA=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 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.31.1 h1:XVU0VyzxrYHlBhIs1DiEgSl0ZtdnPtbLVy8hSkzxGrs=
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= modernc.org/sqlite v1.31.1/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -11,6 +11,7 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server" "github.com/marcopeocchi/yt-dlp-web-ui/server"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
) )
var ( var (
@@ -91,6 +92,8 @@ func main() {
log.Println(cli.BgRed, "config", cli.Reset, err) log.Println(cli.BgRed, "config", cli.Reset, err)
} }
openid.Configure()
server.RunBlocking(&server.RunConfig{ server.RunBlocking(&server.RunConfig{
Host: c.Host, Host: c.Host,
Port: c.Port, Port: c.Port,

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"path/filepath"
"sync" "sync"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -10,6 +11,7 @@ import (
type Config struct { type Config struct {
CurrentLogFile string CurrentLogFile string
LogPath string `yaml:"log_path"` LogPath string `yaml:"log_path"`
BaseURL string `yaml:"base_url"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"` DownloadPath string `yaml:"downloadPath"`
@@ -19,6 +21,13 @@ type Config struct {
Password string `yaml:"password"` Password string `yaml:"password"`
QueueSize int `yaml:"queue_size"` QueueSize int `yaml:"queue_size"`
SessionFilePath string `yaml:"session_file_path"` SessionFilePath string `yaml:"session_file_path"`
path string
UseOpenId bool `yaml:"use_openid"`
OpenIdProviderURL string `yaml:"openid_provider_url"`
OpenIdClientId string `yaml:"openid_client_id"`
OpenIdClientSecret string `yaml:"openid_client_secret"`
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
} }
var ( var (
@@ -42,9 +51,17 @@ func (c *Config) LoadFile(filename string) error {
return err return err
} }
c.path = filename
if err := yaml.NewDecoder(fd).Decode(c); err != nil { if err := yaml.NewDecoder(fd).Decode(c); err != nil {
return err return err
} }
return nil return nil
} }
// Path of the directory containing the config file
func (c *Config) Dir() string { return filepath.Dir(c.path) }
// Absolute path of the config file
func (c *Config) Path() string { return c.path }

View File

@@ -3,25 +3,56 @@ package dbutil
import ( import (
"context" "context"
"database/sql" "database/sql"
"os"
"path/filepath"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock")
// Run the table migration // Run the table migration
func AutoMigrate(ctx context.Context, db *sql.DB) error { func Migrate(ctx context.Context, db *sql.DB) error {
conn, err := db.Conn(ctx) conn, err := db.Conn(ctx)
if err != nil { if err != nil {
return err return err
} }
defer conn.Close() defer func() {
conn.Close()
createLockFile()
}()
_, err = db.ExecContext( if _, err := db.ExecContext(
ctx, ctx,
`CREATE TABLE IF NOT EXISTS templates ( `CREATE TABLE IF NOT EXISTS templates (
id CHAR(36) PRIMARY KEY, id CHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
content TEXT NOT NULL content TEXT NOT NULL
)`, )`,
); err != nil {
return err
}
if lockFileExists() {
return nil
}
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 return nil
}
func createLockFile() { os.Create(lockFilePath) }
func lockFileExists() bool {
_, err := os.Stat(lockFilePath)
return os.IsExist(err)
} }

View File

@@ -158,7 +158,6 @@ func SendFile(w http.ResponseWriter, r *http.Request) {
root := config.Instance().DownloadPath root := config.Instance().DownloadPath
// TODO: further path / file validations
if strings.Contains(filepath.Dir(filename), root) { if strings.Contains(filepath.Dir(filename), root) {
http.ServeFile(w, r, filename) http.ServeFile(w, r, filename)
return return

View File

@@ -36,6 +36,7 @@ type DownloadInfo struct {
ACodec string `json:"acodec"` ACodec string `json:"acodec"`
Extension string `json:"ext"` Extension string `json:"ext"`
OriginalURL string `json:"original_url"` OriginalURL string `json:"original_url"`
FileName string `json:"filename"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }

View File

@@ -0,0 +1,232 @@
package livestream
import (
"bufio"
"errors"
"io"
"log/slog"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
const (
waiting = iota
inProgress
completed
errored
)
// Defines a generic livestream.
// A livestream is identified by its url.
type LiveStream struct {
url string
proc *os.Process // used to manually kill the yt-dlp process
status int // whether is monitoring or completed
log chan []byte // keeps tracks of the process logs while monitoring, not when started
done chan *LiveStream // where to signal the completition
waitTimeChan chan time.Duration // time to livestream start
errors chan error
waitTime time.Duration
liveDate time.Time
}
func New(url string, log chan []byte, done chan *LiveStream) *LiveStream {
return &LiveStream{
url: url,
done: done,
status: waiting,
waitTime: time.Second * 0,
log: log,
errors: make(chan error),
waitTimeChan: make(chan time.Duration),
}
}
// Start the livestream monitoring process, once completion signals on the done channel
func (l *LiveStream) Start() error {
cmd := exec.Command(
config.Instance().DownloaderPath,
l.url,
"--wait-for-video", "10", // wait for the stream to be live and recheck every 10 secs
"--no-colors", // no ansi color fuzz
"--newline",
"--paths", config.Instance().DownloadPath,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
l.status = errored
return err
}
defer stdout.Close()
stderr, err := cmd.StderrPipe()
if err != nil {
l.status = errored
return err
}
defer stderr.Close()
if err := cmd.Start(); err != nil {
l.status = errored
return err
}
l.proc = cmd.Process
l.status = waiting
// Start monitoring when the livestream is goin to be live.
// If already live do nothing.
doneWaiting := make(chan struct{})
go l.monitorStartTime(stdout, doneWaiting)
go func() {
<-doneWaiting
l.logFFMpeg(io.MultiReader(stdout, stderr))
}()
// Wait to the yt-dlp+ffmpeg process to finish.
cmd.Wait()
// Set the job as completed and notify the parent the completion.
l.status = completed
l.done <- l
// cleanup
close(doneWaiting)
return nil
}
func (l *LiveStream) monitorStartTime(r io.Reader, doneWait chan struct{}) {
// yt-dlp shows the time in the stdout
scanner := bufio.NewScanner(r)
defer func() {
l.status = inProgress
doneWait <- struct{}{}
close(l.waitTimeChan)
close(l.errors)
}()
// however the time to live is not shown in a new line (and atm there's nothing to do about)
// use a custom split funciton to set the line separator to \r instead of \r\n or \n
scanner.Split(stdoutSplitFunc)
waitTimeScanner := func() {
for scanner.Scan() {
// l.log <- scanner.Bytes()
// if this substring is in the current line the download is starting,
// no need to monitor the time to live.
//TODO: silly
if !strings.Contains(scanner.Text(), "Remaining time until next attempt") {
return
}
parts := strings.Split(scanner.Text(), ": ")
if len(parts) < 2 {
continue
}
startsIn := parts[1]
parsed, err := parseTimeSpan(startsIn)
if err != nil {
continue
}
l.liveDate = parsed
//TODO: check if using channels is stupid or not
// l.waitTimeChan <- time.Until(start)
l.waitTime = time.Until(parsed)
}
}
const TRIES = 5
/*
if it's waiting a livestream the 5th line will indicate the time to live
its a dumb and not robust method.
example:
[youtube] Extracting URL: https://www.youtube.com/watch?v=IQVbGfVVjgY
[youtube] IQVbGfVVjgY: Downloading webpage
[youtube] IQVbGfVVjgY: Downloading ios player API JSON
[youtube] IQVbGfVVjgY: Downloading web creator player API JSON
WARNING: [youtube] This live event will begin in 27 minutes. <- STDERR, ignore
[wait] Waiting for 00:27:15 - Press Ctrl+C to try now <- 5th line
*/
for range TRIES {
scanner.Scan()
line := scanner.Text()
if strings.Contains(line, "Waiting for") {
waitTimeScanner()
}
}
}
func (l *LiveStream) WaitTime() <-chan time.Duration {
return l.waitTimeChan
}
// Kills a livestream process and signal its completition
func (l *LiveStream) Kill() error {
l.done <- l
if l.proc != nil {
return l.proc.Kill()
}
return errors.New("nil yt-dlp process")
}
// Parse the timespan returned from yt-dlp (time to live)
//
// parsed := parseTimeSpan("76:12:15")
// fmt.Println(parsed) // 2024-07-21 13:59:59.634781 +0200 CEST
func parseTimeSpan(timeStr string) (time.Time, error) {
parts := strings.Split(timeStr, ":")
hh, err := strconv.Atoi(parts[0])
if err != nil {
return time.Time{}, err
}
mm, err := strconv.Atoi(parts[1])
if err != nil {
return time.Time{}, err
}
ss, err := strconv.Atoi(parts[2])
if err != nil {
return time.Time{}, err
}
dd := 0
if hh > 24 {
dd = hh / 24
hh = hh % 24
}
start := time.Now()
start = start.AddDate(0, 0, dd)
start = start.Add(time.Duration(hh) * time.Hour)
start = start.Add(time.Duration(mm) * time.Minute)
start = start.Add(time.Duration(ss) * time.Second)
return start, nil
}
func (l *LiveStream) logFFMpeg(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
slog.Info("livestream ffmpeg output", slog.String("url", l.url), slog.String("stdout", scanner.Text()))
}
}

View File

@@ -0,0 +1,36 @@
package livestream
import (
"testing"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
func setupTest() {
config.Instance().DownloaderPath = "yt-dlp"
}
func TestLivestream(t *testing.T) {
setupTest()
done := make(chan *LiveStream)
log := make(chan []byte)
ls := New("https://www.youtube.com/watch?v=LSm1daKezcE", log, done)
go ls.Start()
time.AfterFunc(time.Second*20, func() {
ls.Kill()
})
for {
select {
case wt := <-ls.WaitTime():
t.Log(wt)
case <-done:
t.Log("done")
return
}
}
}

View File

@@ -0,0 +1,118 @@
package livestream
import (
"encoding/gob"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
type Monitor struct {
streams map[string]*LiveStream // keeps track of the livestreams
done chan *LiveStream // to signal individual processes completition
logs chan []byte // to signal individual processes completition
}
func NewMonitor() *Monitor {
return &Monitor{
streams: make(map[string]*LiveStream),
done: make(chan *LiveStream),
}
}
// Detect each livestream completition, if done remove it from the monitor.
func (m *Monitor) Schedule() {
for l := range m.done {
delete(m.streams, l.url)
}
}
func (m *Monitor) Add(url string) {
ls := New(url, m.logs, m.done)
go ls.Start()
m.streams[url] = ls
}
func (m *Monitor) Remove(url string) error {
return m.streams[url].Kill()
}
func (m *Monitor) RemoveAll() error {
for _, v := range m.streams {
if err := v.Kill(); err != nil {
return err
}
}
return nil
}
func (m *Monitor) Status() LiveStreamStatus {
status := make(LiveStreamStatus)
for k, v := range m.streams {
// wt, ok := <-v.WaitTime()
// if !ok {
// continue
// }
status[k] = struct {
Status int
WaitTime time.Duration
LiveDate time.Time
}{
Status: v.status,
WaitTime: v.waitTime,
LiveDate: v.liveDate,
}
}
return status
}
// Persist the monitor current state to a file.
// The file is located in the configured config directory
func (m *Monitor) Persist() error {
fd, err := os.Create(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
if err != nil {
return err
}
defer fd.Close()
slog.Debug("persisting livestream monitor state")
return gob.NewEncoder(fd).Encode(m.streams)
}
// Restore a saved state and resume the monitored livestreams
func (m *Monitor) Restore() error {
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
if err != nil {
return err
}
defer fd.Close()
restored := make(map[string]*LiveStream)
if err := gob.NewDecoder(fd).Decode(&restored); err != nil {
return err
}
for k := range restored {
m.Add(k)
}
slog.Debug("restored livestream monitor state")
return nil
}
// Return a fan-in logs channel
func (m *Monitor) Logs() <-chan []byte {
return m.logs
}

View File

@@ -0,0 +1 @@
package livestream

View File

@@ -0,0 +1,11 @@
package livestream
import "time"
type LiveStreamStatus = map[string]Status
type Status = struct {
Status int
WaitTime time.Duration
LiveDate time.Time
}

View File

@@ -0,0 +1,16 @@
package livestream
import "bufio"
var stdoutSplitFunc = func(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i := 0; i < len(data); i++ {
if data[i] == '\r' || data[i] == '\n' {
return i + 1, data[:i], nil
}
}
if !atEOF {
return 0, nil, nil
}
return 0, data, bufio.ErrFinalToken
}

View File

@@ -3,7 +3,6 @@ package internal
import ( import (
"encoding/gob" "encoding/gob"
"errors" "errors"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -92,7 +91,7 @@ func (m *MemoryDB) Persist() error {
} }
// Restore a persisted state // Restore a persisted state
func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) { func (m *MemoryDB) Restore(mq *MessageQueue) {
fd, err := os.Open("session.dat") fd, err := os.Open("session.dat")
if err != nil { if err != nil {
return return
@@ -112,7 +111,6 @@ func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) {
Progress: proc.Progress, Progress: proc.Progress,
Output: proc.Output, Output: proc.Output,
Params: proc.Params, Params: proc.Params,
Logger: logger,
} }
m.table.Store(proc.Id, restored) m.table.Store(proc.Id, restored)

View File

@@ -15,14 +15,13 @@ const queueName = "process:pending"
type MessageQueue struct { type MessageQueue struct {
concurrency int concurrency int
eventBus evbus.Bus eventBus evbus.Bus
logger *slog.Logger
} }
// Creates a new message queue. // Creates a new message queue.
// By default it will be created with a size equals to nthe number of logical // By default it will be created with a size equals to nthe number of logical
// CPU cores -1. // CPU cores -1.
// The queue size can be set via the qs flag. // The queue size can be set via the qs flag.
func NewMessageQueue(l *slog.Logger) (*MessageQueue, error) { func NewMessageQueue() (*MessageQueue, error) {
qs := config.Instance().QueueSize qs := config.Instance().QueueSize
if qs <= 0 { if qs <= 0 {
@@ -32,7 +31,6 @@ func NewMessageQueue(l *slog.Logger) (*MessageQueue, error) {
return &MessageQueue{ return &MessageQueue{
concurrency: qs, concurrency: qs,
eventBus: evbus.New(), eventBus: evbus.New(),
logger: l,
}, nil }, nil
} }
@@ -55,19 +53,20 @@ func (m *MessageQueue) downloadConsumer() {
sem := semaphore.NewWeighted(int64(m.concurrency)) sem := semaphore.NewWeighted(int64(m.concurrency))
m.eventBus.SubscribeAsync(queueName, func(p *Process) { m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context sem.Acquire(context.Background(), 1)
sem.Acquire(context.TODO(), 1)
defer sem.Release(1) defer sem.Release(1)
m.logger.Info("received process from event bus", slog.Info("received process from event bus",
slog.String("bus", queueName), slog.String("bus", queueName),
slog.String("consumer", "downloadConsumer"), slog.String("consumer", "downloadConsumer"),
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
) )
if p.Progress.Status != StatusCompleted {
p.Start() p.Start()
}
m.logger.Info("started process", slog.Info("started process",
slog.String("bus", queueName), slog.String("bus", queueName),
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
) )
@@ -82,18 +81,25 @@ func (m *MessageQueue) metadataSubscriber() {
sem := semaphore.NewWeighted(1) sem := semaphore.NewWeighted(1)
m.eventBus.SubscribeAsync(queueName, func(p *Process) { m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context sem.Acquire(context.Background(), 1)
sem.Acquire(context.TODO(), 1)
defer sem.Release(1) defer sem.Release(1)
m.logger.Info("received process from event bus", slog.Info("received process from event bus",
slog.String("bus", queueName), slog.String("bus", queueName),
slog.String("consumer", "metadataConsumer"), slog.String("consumer", "metadataConsumer"),
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
) )
if p.Progress.Status == StatusCompleted {
slog.Warn("proccess has an illegal state",
slog.String("id", p.getShortId()),
slog.Int("status", p.Progress.Status),
)
return
}
if err := p.SetMetadata(); err != nil { if err := p.SetMetadata(); err != nil {
m.logger.Error("failed to retrieve metadata", slog.Error("failed to retrieve metadata",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("err", err.Error()), slog.String("err", err.Error()),
) )

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"os/exec" "os/exec"
"slices"
"strings" "strings"
"time" "time"
@@ -18,10 +19,10 @@ type metadata struct {
Type string `json:"_type"` Type string `json:"_type"`
} }
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger *slog.Logger) error { func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
var ( var (
downloader = config.Instance().DownloaderPath downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "-J") cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J")
) )
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
@@ -35,27 +36,30 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
return err return err
} }
logger.Info("decoding playlist metadata", slog.String("url", req.URL)) slog.Info("decoding playlist metadata", slog.String("url", req.URL))
if err := json.NewDecoder(stdout).Decode(&m); err != nil { if err := json.NewDecoder(stdout).Decode(&m); err != nil {
return err return err
} }
logger.Info("decoded metadata", slog.String("url", req.URL)) if err := cmd.Wait(); err != nil {
return err
}
slog.Info("decoded playlist metadata", slog.String("url", req.URL))
if m.Type == "" { if m.Type == "" {
cmd.Wait()
return errors.New("probably not a valid URL") return errors.New("probably not a valid URL")
} }
if m.Type == "playlist" { if m.Type == "playlist" {
logger.Info( entries := slices.CompactFunc(slices.Compact(m.Entries), func(a DownloadInfo, b DownloadInfo) bool {
"playlist detected", return a.URL == b.URL
slog.String("url", req.URL), })
slog.Int("count", m.Count),
)
for _, meta := range m.Entries { slog.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 // detect playlist title from metadata since each playlist entry will be
// treated as an individual download // treated as an individual download
req.Rename = strings.Replace( req.Rename = strings.Replace(
@@ -65,36 +69,34 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
1, 1,
) )
//XXX: it's idiotic but it works: virtually delay the creation time
meta.CreatedAt = time.Now().Add(time.Millisecond * time.Duration(i*10))
proc := &Process{ proc := &Process{
Url: meta.OriginalURL, Url: meta.URL,
Progress: DownloadProgress{}, Progress: DownloadProgress{},
Output: DownloadOutput{Filename: req.Rename}, Output: DownloadOutput{Filename: req.Rename},
Info: meta, Info: meta,
Params: req.Params, Params: req.Params,
Logger: logger,
} }
proc.Info.URL = meta.OriginalURL proc.Info.URL = meta.URL
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
db.Set(proc) db.Set(proc)
mq.Publish(proc) mq.Publish(proc)
} }
err = cmd.Wait()
return err
} }
proc := &Process{ proc := &Process{
Url: req.URL, Url: req.URL,
Params: req.Params, Params: req.Params,
Logger: logger,
} }
db.Set(proc) db.Set(proc)
mq.Publish(proc) mq.Publish(proc)
logger.Info("sending new process to message queue", slog.String("url", proc.Url)) slog.Info("sending new process to message queue", slog.String("url", proc.Url))
return cmd.Wait() return cmd.Wait()
} }

View File

@@ -47,7 +47,6 @@ type Process struct {
Progress DownloadProgress Progress DownloadProgress
Output DownloadOutput Output DownloadOutput
proc *os.Process proc *os.Process
Logger *slog.Logger
} }
// Starts spawns/forks a new yt-dlp process and parse its stdout. // Starts spawns/forks a new yt-dlp process and parse its stdout.
@@ -108,7 +107,7 @@ func (p *Process) Start() {
r, err := cmd.StdoutPipe() r, err := cmd.StdoutPipe()
if err != nil { if err != nil {
p.Logger.Error( slog.Error(
"failed to connect to stdout", "failed to connect to stdout",
slog.String("err", err.Error()), slog.String("err", err.Error()),
) )
@@ -116,7 +115,7 @@ func (p *Process) Start() {
} }
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
p.Logger.Error( slog.Error(
"failed to start yt-dlp process", "failed to start yt-dlp process",
slog.String("err", err.Error()), slog.String("err", err.Error()),
) )
@@ -125,8 +124,6 @@ func (p *Process) Start() {
p.proc = cmd.Process p.proc = cmd.Process
go p.SetMetadata()
// --------------- progress block --------------- // // --------------- progress block --------------- //
var ( var (
sourceChan = make(chan []byte) sourceChan = make(chan []byte)
@@ -169,7 +166,7 @@ func (p *Process) Start() {
ETA: progress.Eta, ETA: progress.Eta,
} }
p.Logger.Info("progress", slog.Info("progress",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("percentage", progress.Percentage), slog.String("percentage", progress.Percentage),
@@ -192,7 +189,7 @@ func (p *Process) Complete() {
ETA: 0, ETA: 0,
} }
p.Logger.Info("finished", slog.Info("finished",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
) )
@@ -200,18 +197,22 @@ func (p *Process) Complete() {
// Kill a process and remove it from the memory // Kill a process and remove it from the memory
func (p *Process) Kill() error { func (p *Process) Kill() error {
defer func() {
p.Progress.Status = StatusCompleted
}()
// yt-dlp uses multiple child process the parent process // yt-dlp uses multiple child process the parent process
// has been spawned with setPgid = true. To properly kill // has been spawned with setPgid = true. To properly kill
// all subprocesses a SIGTERM need to be sent to the correct // all subprocesses a SIGTERM need to be sent to the correct
// process group // process group
if p.proc != nil { if p.proc == nil {
return errors.New("*os.Process not set")
}
pgid, err := syscall.Getpgid(p.proc.Pid) pgid, err := syscall.Getpgid(p.proc.Pid)
if err != nil { if err != nil {
return err return err
} }
err = syscall.Kill(-pgid, syscall.SIGTERM) if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil {
p.Logger.Info("killed process", slog.String("id", p.Id))
return err return err
} }
@@ -220,15 +221,12 @@ func (p *Process) Kill() error {
// Returns the available format for this URL // Returns the available format for this URL
// TODO: Move out from process.go // TODO: Move out from process.go
func (p *Process) GetFormatsSync() (DownloadFormats, error) { func (p *Process) GetFormats() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J") cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output() stdout, err := cmd.Output()
if err != nil { if err != nil {
p.Logger.Error( slog.Error("failed to retrieve metadata", slog.String("err", err.Error()))
"failed to retrieve metadata",
slog.String("err", err.Error()),
)
return DownloadFormats{}, err return DownloadFormats{}, err
} }
@@ -248,7 +246,7 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
p.Url, p.Url,
) )
p.Logger.Info( slog.Info(
"retrieving metadata", "retrieving metadata",
slog.String("caller", "getFormats"), slog.String("caller", "getFormats"),
slog.String("url", p.Url), slog.String("url", p.Url),
@@ -258,7 +256,6 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
decodingError = json.Unmarshal(stdout, &info) decodingError = json.Unmarshal(stdout, &info)
wg.Done() wg.Done()
}() }()
go func() { go func() {
decodingError = json.Unmarshal(stdout, &best) decodingError = json.Unmarshal(stdout, &best)
wg.Done() wg.Done()
@@ -309,7 +306,7 @@ func (p *Process) SetMetadata() error {
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
p.Logger.Error("failed to connect to stdout", slog.Error("failed to connect to stdout",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("err", err.Error()), slog.String("err", err.Error()),
@@ -319,7 +316,7 @@ func (p *Process) SetMetadata() error {
stderr, err := cmd.StderrPipe() stderr, err := cmd.StderrPipe()
if err != nil { if err != nil {
p.Logger.Error("failed to connect to stderr", slog.Error("failed to connect to stderr",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("err", err.Error()), slog.String("err", err.Error()),
@@ -342,7 +339,7 @@ func (p *Process) SetMetadata() error {
io.Copy(&bufferedStderr, stderr) io.Copy(&bufferedStderr, stderr)
}() }()
p.Logger.Info("retrieving metadata", slog.Info("retrieving metadata",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
) )

View File

@@ -11,6 +11,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@@ -61,7 +62,10 @@ func sse(w http.ResponseWriter, r *http.Request) {
} }
sb.WriteString("event: log\n") sb.WriteString("event: log\n")
sb.WriteString("data: " + b.String() + "\n\n") sb.WriteString("data: ")
sb.WriteString(b.String())
sb.WriteRune('\n')
sb.WriteRune('\n')
fmt.Fprint(w, sb.String()) fmt.Fprint(w, sb.String())
@@ -74,6 +78,9 @@ func ApplyRouter() func(chi.Router) {
if config.Instance().RequireAuth { if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated) r.Use(middlewares.Authenticated)
} }
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Get("/ws", webSocket) r.Get("/ws", webSocket)
r.Get("/sse", sse) r.Get("/sse", sse)
} }

37
server/openid/config.go Normal file
View File

@@ -0,0 +1,37 @@
package openid
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"golang.org/x/oauth2"
)
var (
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
)
func Configure() {
if !config.Instance().UseOpenId {
return
}
provider, err := oidc.NewProvider(context.Background(), config.Instance().OpenIdProviderURL)
if err != nil {
panic(err)
}
oauth2Config = oauth2.Config{
ClientID: config.Instance().OpenIdClientId,
ClientSecret: config.Instance().OpenIdClientSecret,
RedirectURL: config.Instance().OpenIdRedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
verifier = provider.Verifier(&oidc.Config{
ClientID: config.Instance().OpenIdClientId,
})
}

178
server/openid/handler.go Normal file
View File

@@ -0,0 +1,178 @@
package openid
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid"
"golang.org/x/oauth2"
)
type OAuth2SuccessResponse struct {
OAuth2Token *oauth2.Token
IDTokenClaims *json.RawMessage
}
// var cookieMaxAge = int(time.Hour * 24 * 30) XXX: overflows on 32 bit architectures.
func Login(w http.ResponseWriter, r *http.Request) {
state := uuid.NewString()
nonceBytes := make([]byte, 16)
rand.Read(nonceBytes)
nonce := hex.EncodeToString(nonceBytes)
http.SetCookie(w, &http.Cookie{
Name: "state",
Value: state,
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
// MaxAge: cookieMaxAge,
Expires: time.Now().Add(time.Hour * 24 * 30), // XXX: change to MaxAge
})
http.SetCookie(w, &http.Cookie{
Name: "nonce",
Value: nonce,
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
// MaxAge: cookieMaxAge,
Expires: time.Now().Add(time.Hour * 24 * 30), // XXX: change to MaxAge
})
http.Redirect(w, r, oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
}
func doAuthentification(r *http.Request, setCookieCallback func(t *oauth2.Token)) (*OAuth2SuccessResponse, error) {
state, err := r.Cookie("state")
if err != nil {
return nil, err
}
if r.URL.Query().Get("state") != state.Value {
return nil, errors.New("auth state does not match")
}
oauth2Token, err := oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
return nil, err
}
rawToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return nil, errors.New("openid field \"id_token\" not found in oauth2 token")
}
idToken, err := verifier.Verify(r.Context(), rawToken)
if err != nil {
return nil, err
}
nonce, err := r.Cookie("nonce")
if err != nil {
return nil, err
}
if idToken.Nonce != nonce.Value {
return nil, errors.New("auth nonce does not match")
}
setCookieCallback(oauth2Token)
// redact
oauth2Token.AccessToken = "*REDACTED*"
res := OAuth2SuccessResponse{
oauth2Token,
&json.RawMessage{},
}
if err := idToken.Claims(&res.IDTokenClaims); err != nil {
return nil, err
}
return &res, nil
}
func SingIn(w http.ResponseWriter, r *http.Request) {
_, err := doAuthentification(r, func(t *oauth2.Token) {
idToken, _ := t.Extra("id_token").(string)
http.SetCookie(w, &http.Cookie{
Name: "oid-token",
Value: idToken,
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
// MaxAge: int(time.Hour * 24 * 30), XXX: overflows on 32 bit architectures.
})
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Write([]byte("Login succesfully, you may now close this window and refresh yt-dlp-webui."))
}
func Refresh(w http.ResponseWriter, r *http.Request) {
refreshToken := r.URL.Query().Get("refresh-token")
ts := oauth2Config.TokenSource(r.Context(), &oauth2.Token{RefreshToken: refreshToken})
token, err := ts.Token()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oid-token",
Value: token.AccessToken,
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
// MaxAge: int(time.Hour * 24 * 30), XXX: overflows on 32 bit architectures.
})
token.AccessToken = "*redacted*"
if err := json.NewEncoder(w).Encode(token); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "oid-token",
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
MaxAge: -1,
})
http.SetCookie(w, &http.Cookie{
Name: "state",
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
MaxAge: -1,
})
http.SetCookie(w, &http.Cookie{
Name: "nonce",
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
MaxAge: -1,
})
}

View File

@@ -0,0 +1,20 @@
package openid
import "net/http"
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, err := r.Cookie("oid-token")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err := verifier.Verify(r.Context(), token.Value); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -2,7 +2,6 @@ package rest
import ( import (
"database/sql" "database/sql"
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
@@ -11,5 +10,4 @@ type ContainerArgs struct {
DB *sql.DB DB *sql.DB
MDB *internal.MemoryDB MDB *internal.MemoryDB
MQ *internal.MessageQueue MQ *internal.MessageQueue
Logger *slog.Logger
} }

View File

@@ -4,6 +4,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
) )
func Container(args *ContainerArgs) *Handler { func Container(args *ContainerArgs) *Handler {
@@ -21,6 +22,9 @@ func ApplyRouter(args *ContainerArgs) func(chi.Router) {
if config.Instance().RequireAuth { if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated) r.Use(middlewares.Authenticated)
} }
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Post("/exec", h.Exec()) r.Post("/exec", h.Exec())
r.Get("/running", h.Running()) r.Get("/running", h.Running())
r.Get("/version", h.GetVersion()) r.Get("/version", h.GetVersion())

View File

@@ -18,7 +18,6 @@ func ProvideService(args *ContainerArgs) *Service {
mdb: args.MDB, mdb: args.MDB,
db: args.DB, db: args.DB,
mq: args.MQ, mq: args.MQ,
logger: args.Logger,
} }
}) })
return service return service

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"time" "time"
@@ -18,7 +17,6 @@ type Service struct {
mdb *internal.MemoryDB mdb *internal.MemoryDB
db *sql.DB db *sql.DB
mq *internal.MessageQueue mq *internal.MessageQueue
logger *slog.Logger
} }
func (s *Service) Exec(req internal.DownloadRequest) (string, error) { func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
@@ -29,7 +27,6 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
Path: req.Path, Path: req.Path,
Filename: req.Rename, Filename: req.Rename,
}, },
Logger: s.logger,
} }
id := s.mdb.Set(p) id := s.mdb.Set(p)

View File

@@ -1,24 +1,20 @@
package rpc package rpc
import ( import (
"log/slog"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
) )
// Dependency injection container. // Dependency injection container.
func Container( func Container(db *internal.MemoryDB, mq *internal.MessageQueue, lm *livestream.Monitor) *Service {
db *internal.MemoryDB,
mq *internal.MessageQueue,
logger *slog.Logger,
) *Service {
return &Service{ return &Service{
db: db, db: db,
mq: mq, mq: mq,
logger: logger, lm: lm,
} }
} }
@@ -28,6 +24,9 @@ func ApplyRouter() func(chi.Router) {
if config.Instance().RequireAuth { if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated) r.Use(middlewares.Authenticated)
} }
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Get("/ws", WebSocket) r.Get("/ws", WebSocket)
r.Post("/http", Post) r.Post("/http", Post)
} }

View File

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

View File

@@ -1,9 +1,11 @@
package rpc package rpc
import ( import (
"errors"
"log/slog" "log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys" "github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater" "github.com/marcopeocchi/yt-dlp-web-ui/server/updater"
) )
@@ -11,7 +13,7 @@ import (
type Service struct { type Service struct {
db *internal.MemoryDB db *internal.MemoryDB
mq *internal.MessageQueue mq *internal.MessageQueue
logger *slog.Logger lm *livestream.Monitor
} }
type Running []internal.ProcessResponse type Running []internal.ProcessResponse
@@ -35,7 +37,6 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
Path: args.Path, Path: args.Path,
Filename: args.Rename, Filename: args.Rename,
}, },
Logger: s.logger,
} }
s.db.Set(p) s.db.Set(p)
@@ -48,7 +49,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
// Exec spawns a Process. // Exec spawns a Process.
// The result of the execution is the newly spawned process Id. // The result of the execution is the newly spawned process Id.
func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error { func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error {
err := internal.PlaylistDetect(args, s.mq, s.db, s.logger) err := internal.PlaylistDetect(args, s.mq, s.db)
if err != nil { if err != nil {
return err return err
} }
@@ -57,6 +58,38 @@ func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) er
return nil return nil
} }
// TODO: docs
func (s *Service) ExecLivestream(args internal.DownloadRequest, result *string) error {
s.lm.Add(args.URL)
*result = args.URL
return nil
}
// TODO: docs
func (s *Service) ProgressLivestream(args NoArgs, result *livestream.LiveStreamStatus) error {
*result = s.lm.Status()
return nil
}
// TODO: docs
func (s *Service) KillLivestream(args string, result *struct{}) error {
slog.Info("killing livestream", slog.String("url", args))
err := s.lm.Remove(args)
if err != nil {
slog.Error("failed killing livestream", slog.String("url", args), slog.Any("err", err))
return err
}
return nil
}
// TODO: docs
func (s *Service) KillAllLivestream(args NoArgs, result *struct{}) error {
return s.lm.RemoveAll()
}
// Progess retrieves the Progress of a specific Process given its Id // Progess retrieves the Progress of a specific Process given its Id
func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error { func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error {
proc, err := s.db.Get(args.Id) proc, err := s.db.Get(args.Id)
@@ -72,9 +105,9 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error { func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
var ( var (
err error err error
p = internal.Process{Url: args.URL, Logger: s.logger} p = internal.Process{Url: args.URL}
) )
*meta, err = p.GetFormatsSync() *meta, err = p.GetFormats()
return err return err
} }
@@ -92,30 +125,39 @@ func (s *Service) Running(args NoArgs, running *Running) error {
// Kill kills a process given its id and remove it from the memoryDB // Kill kills a process given its id and remove it from the memoryDB
func (s *Service) Kill(args string, killed *string) error { func (s *Service) Kill(args string, killed *string) error {
s.logger.Info("Trying killing process with id", slog.String("id", args)) slog.Info("Trying killing process with id", slog.String("id", args))
proc, err := s.db.Get(args) proc, err := s.db.Get(args)
if err != nil { if err != nil {
return err return err
} }
if proc != nil { if proc == nil {
err = proc.Kill() return errors.New("nil process")
s.db.Delete(proc.Id) }
if err := proc.Kill(); err != nil {
slog.Info("failed killing process", slog.String("id", proc.Id), slog.Any("err", err))
return err
} }
s.db.Delete(proc.Id) s.db.Delete(proc.Id)
return err slog.Info("succesfully killed process", slog.String("id", proc.Id))
return nil
} }
// KillAll kills all process unconditionally and removes them from // KillAll kills all process unconditionally and removes them from
// the memory db // the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error { func (s *Service) KillAll(args NoArgs, killed *string) error {
s.logger.Info("Killing all spawned processes") slog.Info("Killing all spawned processes")
var ( var (
keys = s.db.Keys() keys = s.db.Keys()
err error removeFunc = func(p *internal.Process) error {
defer s.db.Delete(p.Id)
return p.Kill()
}
) )
for _, key := range *keys { for _, key := range *keys {
@@ -124,18 +166,29 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
return err return err
} }
if proc != nil { if proc == nil {
proc.Kill() s.db.Delete(key)
s.db.Delete(proc.Id) continue
}
} }
return err if err := removeFunc(proc); err != nil {
slog.Info(
"failed killing process",
slog.String("id", proc.Id),
slog.Any("err", err),
)
continue
}
slog.Info("succesfully killed process", slog.String("id", proc.Id))
}
return nil
} }
// Remove a process from the db rendering it unusable if active // Remove a process from the db rendering it unusable if active
func (s *Service) Clear(args string, killed *string) error { func (s *Service) Clear(args string, killed *string) error {
s.logger.Info("Clearing process with id", slog.String("id", args)) slog.Info("Clearing process with id", slog.String("id", args))
s.db.Delete(args) s.db.Delete(args)
return nil return nil
} }
@@ -169,16 +222,16 @@ func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
// Updates the yt-dlp binary using its builtin function // Updates the yt-dlp binary using its builtin function
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error { func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
s.logger.Info("Updating yt-dlp executable to the latest release") slog.Info("Updating yt-dlp executable to the latest release")
if err := updater.UpdateExecutable(); err != nil { if err := updater.UpdateExecutable(); err != nil {
s.logger.Error("Failed updating yt-dlp") slog.Error("Failed updating yt-dlp")
*updated = false *updated = false
return err return err
} }
*updated = true *updated = true
s.logger.Info("Succesfully updated yt-dlp") slog.Info("Succesfully updated yt-dlp")
return nil return nil
} }

View File

@@ -1,3 +1,4 @@
// a stupid package name...
package server package server
import ( import (
@@ -22,8 +23,10 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server/dbutil" "github.com/marcopeocchi/yt-dlp-web-ui/server/dbutil"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers" "github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging" "github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest" "github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc" ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
@@ -43,7 +46,6 @@ type RunConfig struct {
type serverConfig struct { type serverConfig struct {
frontend fs.FS frontend fs.FS
swagger fs.FS swagger fs.FS
logger *slog.Logger
host string host string
port int port int
mdb *internal.MemoryDB mdb *internal.MemoryDB
@@ -56,9 +58,10 @@ func RunBlocking(cfg *RunConfig) {
logWriters := []io.Writer{ logWriters := []io.Writer{
os.Stdout, os.Stdout,
logging.NewObservableLogger(), logging.NewObservableLogger(), // for web-ui
} }
// file based logging
if cfg.FileLogging { if cfg.FileLogging {
logger, err := logging.NewRotableLogger(cfg.LogFile) logger, err := logging.NewRotableLogger(cfg.LogFile)
if err != nil { if err != nil {
@@ -75,31 +78,33 @@ func RunBlocking(cfg *RunConfig) {
logWriters = append(logWriters, logger) logWriters = append(logWriters, logger)
} }
logger := slog.New( logger := slog.New(slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{
slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}), Level: slog.LevelInfo, // TODO: detect when launched in debug mode -> slog.LevelDebug
) }))
// make the new logger the default one with all the new writers
slog.SetDefault(logger)
db, err := sql.Open("sqlite", cfg.DBPath) db, err := sql.Open("sqlite", cfg.DBPath)
if err != nil { if err != nil {
logger.Error("failed to open database", slog.String("err", err.Error())) slog.Error("failed to open database", slog.String("err", err.Error()))
} }
if err := dbutil.AutoMigrate(context.Background(), db); err != nil { if err := dbutil.Migrate(context.Background(), db); err != nil {
logger.Error("failed to init database", slog.String("err", err.Error())) slog.Error("failed to init database", slog.String("err", err.Error()))
} }
mq, err := internal.NewMessageQueue(logger) mq, err := internal.NewMessageQueue()
if err != nil { if err != nil {
panic(err) panic(err)
} }
mq.SetupConsumers() mq.SetupConsumers()
go mdb.Restore(mq, logger) go mdb.Restore(mq)
srv := newServer(serverConfig{ srv := newServer(serverConfig{
frontend: cfg.App, frontend: cfg.App,
swagger: cfg.Swagger, swagger: cfg.Swagger,
logger: logger,
host: cfg.Host, host: cfg.Host,
port: cfg.Port, port: cfg.Port,
mdb: &mdb, mdb: &mdb,
@@ -108,13 +113,14 @@ func RunBlocking(cfg *RunConfig) {
}) })
go gracefulShutdown(srv, &mdb) go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &mdb, logger) go autoPersist(time.Minute*5, &mdb)
var ( var (
network = "tcp" network = "tcp"
address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
) )
// support unix sockets
if strings.HasPrefix(cfg.Host, "/") { if strings.HasPrefix(cfg.Host, "/") {
network = "unix" network = "unix"
address = cfg.Host address = cfg.Host
@@ -122,19 +128,30 @@ func RunBlocking(cfg *RunConfig) {
listener, err := net.Listen(network, address) listener, err := net.Listen(network, address)
if err != nil { if err != nil {
logger.Error("failed to listen", slog.String("err", err.Error())) slog.Error("failed to listen", slog.String("err", err.Error()))
return return
} }
logger.Info("yt-dlp-webui started", slog.String("address", address)) slog.Info("yt-dlp-webui started", slog.String("address", address))
if err := srv.Serve(listener); err != nil { if err := srv.Serve(listener); err != nil {
logger.Warn("http server stopped", slog.String("err", err.Error())) slog.Warn("http server stopped", slog.String("err", err.Error()))
} }
} }
func newServer(c serverConfig) *http.Server { func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.mdb, c.mq, c.logger) lm := livestream.NewMonitor()
go lm.Schedule()
go lm.Restore()
go func() {
for {
lm.Persist()
time.Sleep(time.Minute * 5)
}
}()
service := ytdlpRPC.Container(c.mdb, c.mq, lm)
rpc.Register(service) rpc.Register(service)
r := chi.NewRouter() r := chi.NewRouter()
@@ -157,14 +174,20 @@ func newServer(c serverConfig) *http.Server {
// use in dev // use in dev
// r.Use(middleware.Logger) // r.Use(middleware.Logger)
r.Mount("/", http.FileServer(http.FS(c.frontend))) baseUrl := config.Instance().BaseURL
r.Mount("/openapi", http.FileServer(http.FS(c.swagger))) r.Mount(baseUrl+"/", http.StripPrefix(baseUrl, http.FileServerFS(c.frontend)))
// swagger
r.Mount("/openapi", http.FileServerFS(c.swagger))
// Archive routes // Archive routes
r.Route("/archive", func(r chi.Router) { r.Route("/archive", func(r chi.Router) {
if config.Instance().RequireAuth { if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated) r.Use(middlewares.Authenticated)
} }
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Post("/downloaded", handlers.ListDownloaded) r.Post("/downloaded", handlers.ListDownloaded)
r.Post("/delete", handlers.DeleteFile) r.Post("/delete", handlers.DeleteFile)
r.Get("/d/{id}", handlers.DownloadFile) r.Get("/d/{id}", handlers.DownloadFile)
@@ -176,6 +199,12 @@ func newServer(c serverConfig) *http.Server {
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/login", handlers.Login) r.Post("/login", handlers.Login)
r.Get("/logout", handlers.Logout) r.Get("/logout", handlers.Logout)
r.Route("/openid", func(r chi.Router) {
r.Get("/login", openid.Login)
r.Get("/signin", openid.SingIn)
r.Get("/logout", openid.Logout)
})
}) })
// RPC handlers // RPC handlers
@@ -186,7 +215,6 @@ func newServer(c serverConfig) *http.Server {
DB: c.db, DB: c.db,
MDB: c.mdb, MDB: c.mdb,
MQ: c.mq, MQ: c.mq,
Logger: c.logger,
})) }))
// Logging // Logging
@@ -209,20 +237,20 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
defer func() { defer func() {
db.Persist() db.Persist()
stop() stop()
srv.Shutdown(context.TODO()) srv.Shutdown(context.Background())
}() }()
}() }()
} }
func autoPersist(d time.Duration, db *internal.MemoryDB, logger *slog.Logger) { func autoPersist(d time.Duration, db *internal.MemoryDB) {
for { for {
if err := db.Persist(); err != nil { if err := db.Persist(); err != nil {
logger.Info( slog.Warn(
"failed to persisted session", "failed to persisted session",
slog.String("err", err.Error()), slog.String("err", err.Error()),
) )
} }
logger.Info("sucessfully persisted session") slog.Debug("sucessfully persisted session")
time.Sleep(d) time.Sleep(d)
} }
} }