Compare commits

..

144 Commits

Author SHA1 Message Date
566f0f2ac2 download process: added optimistic update. 2024-04-09 09:46:01 +02:00
15ab37de11 code refactoring 2024-03-26 11:34:49 +01:00
Marco Piovanello
f2fab66626 fixed cumulative download bug 2024-03-26 11:24:00 +01:00
Marco Piovanello
86db8176ff Update README.md 2024-03-26 11:05:51 +01:00
1b8d2e0da6 show cumulative download speed
code refactoring
2024-03-26 10:58:03 +01:00
c6e48f4baa code and layout refactoring 2024-03-26 10:10:27 +01:00
82ccb68a56 Layout refactoring, dependencies update 2024-03-25 11:34:40 +01:00
bf2e24009e enabled file logging with log rotation 2024-03-25 11:32:11 +01:00
Boris Rybalkin
e7639c2720 unix socket support (useful behind nginx proxy) (#143) 2024-03-25 09:21:22 +01:00
Marco Piovanello
02832f9de4 code refactoring 2024-03-24 10:50:18 +01:00
29dfebe48b removed 3rd party dependency in favor of std "slices" package
dependencies update
2024-03-24 09:22:17 +01:00
Marco
52862156b9 updated VersionIndicator.tsx 2024-03-22 14:15:50 +01:00
Marco
193ac9f043 Update cosign 2024-03-22 14:02:06 +01:00
Marco
6e4dff5f3a Update README.md 2024-03-22 13:52:17 +01:00
371704db57 ui refactoring 2024-03-22 13:32:08 +01:00
43e5c94b58 display yt-dlp version, multiple downloads enabled.
code refactoring
preparations for optimistic ui updates for new downloads
2024-03-22 13:22:38 +01:00
48c9258088 code and layout refactoring 2024-03-21 10:19:09 +01:00
Marco
87956a6aad Update Makefile 2024-03-18 17:29:12 +01:00
d4305bb2f8 re-enabled armv6 builds
code refactoring
2024-03-18 10:27:40 +01:00
3f836d0fa6 added some comments on the server side 2024-03-18 10:19:39 +01:00
b45107c94b Fixed observable logger, added build stage for frontend
dependencies update

closes #131
2024-03-14 11:59:33 +01:00
0x6d61726b
9cf1a3bc7e removed duplicate/unused file (used in 'frontend/src/assets/', relates to #139) (#140) 2024-03-04 14:40:06 +01:00
df3522fcb3 fixed favicon not showing 2024-03-03 22:32:44 +01:00
e2c27c3857 Added favicon
Closes #139
2024-03-03 20:33:35 +01:00
0x6d61726b
51bcd82ea7 Fixed human-readable file size representation (#137)
(as it follows units of IEC 60027-2 A.2 )
2024-03-03 15:48:56 +01:00
0x6d61726b
f763b9657f Extended config.yml example (#136) 2024-03-03 15:47:52 +01:00
Marco
b9b0fde520 Update README.md 2024-02-05 13:40:50 +01:00
deluxghost
6e123c319f i18n: chinese (#133) 2024-02-05 08:42:26 +01:00
Calm Zhu
de975f758f bugfix: port config in config file not work (#132)
* Update main.go

* update

* Update main.go

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

* test impl for logging

* implemented "live logging", restyle templates dropdown

* moved extract audio to downloadDialog, fixed labels

* code refactoring

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

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

* handle template in playlist download

* code refactoring, dropped goccy go json
2023-10-22 15:54:08 +02:00
Marco
8eb2831bc6 49 feat add cookies (#98)
* build client side validation and submission

* enabled cookies submission, bug fixes
2023-10-21 15:46:24 +02:00
9361d9ce29 code refactoring 2023-10-20 19:20:00 +02:00
d100092f35 toaster refactoring 2023-10-20 18:42:48 +02:00
d64303ccfa removed fmt.Println 2023-10-20 18:26:10 +02:00
6688bc3977 fix encoding url in archive 2023-10-20 18:25:33 +02:00
600475f603 removed buffer polyfill, rewrite with js web standards 2023-10-19 12:12:26 +02:00
da4aaeac84 code refactoring, fixed playlist downloads sorting 2023-10-19 11:29:56 +02:00
NickHoo
2d75030cbc fix: Echo customArgs (#96) 2023-10-12 22:05:39 +02:00
Apix
38bc66cd03 i18n: french (#95)
Updated french translation
2023-10-07 15:41:36 +02:00
3efbb9d464 code refactoring 2023-09-27 13:09:13 +02:00
fa4ba8a211 fix session file path, code refactoring 2023-09-27 13:08:42 +02:00
deluxghost
75ec95041d i18n: chinese (#92) 2023-09-27 13:04:35 +02:00
1b4c8c751b gha refactor 2023-09-26 10:37:21 +02:00
Marco
4710db25ee 82 session file location (#91)
* change session file location

* makefile refactor

* gha refactor
2023-09-26 10:25:14 +02:00
Marco
8337aae438 Update docker-publish.yml 2023-09-25 21:32:20 +02:00
2d104f4539 fix github action 2023-09-25 16:06:19 +02:00
42618b61c9 github action refactor 2023-09-25 15:37:18 +02:00
764c1f4729 code refactoring, fix jwt 2023-09-25 11:12:18 +02:00
9bb5d7bb0d code refactoring, dependencies update 2023-09-25 09:09:12 +02:00
557cf7fbd8 code refactoring 2023-09-25 08:59:58 +02:00
Marco
2040385621 change app title in the ui (#90) 2023-09-25 08:53:44 +02:00
Marco
665a08ddc4 Update docker-publish.yml 2023-09-23 15:29:20 +02:00
Marco
268d4cd990 Update docker-publish.yml 2023-09-23 14:49:31 +02:00
Marco
a9b9973c7f Update docker-publish.yml 2023-09-23 14:47:29 +02:00
Marco
0c304fc3c4 Update docker-publish.yml 2023-09-23 14:45:09 +02:00
Marco
24602b3e50 Update docker-publish.yml 2023-09-23 14:41:14 +02:00
Marco
8e720a15bd Update docker-publish.yml 2023-09-23 14:40:51 +02:00
Marco
9d9a7fa981 Update docker-publish.yml 2023-09-23 13:32:36 +02:00
Marco
d57c440afe change apptitle from settings (#88) 2023-09-23 13:25:02 +02:00
Marco
8bbc8aa35e user and password as authentication requirements (#87)
* user and password as authentication requirements

* updated README.md
2023-09-23 11:41:01 +02:00
Marco
19062c9f41 Update docker-publish.yml 2023-09-23 09:46:11 +02:00
Michael M. Chang
5c8c534df4 use docker username if differs (#84) 2023-09-14 14:35:57 +02:00
Michael M. Chang
a3234955a3 streamline dockerfile and GHA docker publishing (#83) 2023-09-11 17:54:55 +02:00
Le.NoX
13f104646a Update i18n.yaml (#81)
add ( French)
2023-09-05 22:29:44 +02:00
Marco
c50c1f627e 78 404 when the application put under nginx subdirectory with proxy pass (#79)
* use http.FileServer insetead of custom middleware

* fixed behavior under reverse proxy

* enabled reverse proxy subfolder as "domain value"

* domain validation

* code refactoring

* code refactoring

* updated translation
2023-08-21 12:24:50 +02:00
a005f159c6 added roboto font 2023-08-03 12:08:22 +02:00
0607bb4495 code refacoring 2023-08-02 18:52:48 +02:00
Marco
be4641aaf0 use swc vite plugin for dev server (#74) 2023-08-02 18:04:55 +02:00
232cd8e442 remove actions 2023-08-02 18:02:19 +02:00
1442eb8e9d dev container github action 2023-08-02 14:54:41 +02:00
8c57a7bb28 github actions rewrite 2023-08-02 14:47:38 +02:00
Marco
e2dd54add2 Expose config to docker volume (#73)
* expose config to docker volume

* fix dockerfile
2023-08-02 11:54:27 +02:00
db5097c889 hotfix formats 2023-08-01 14:43:52 +02:00
Marco
50a04075a3 Update README.md 2023-08-01 12:06:53 +02:00
4bc5e5e1c7 detect system theme, toast performance opt. 2023-08-01 11:52:50 +02:00
13dd9526e2 Removed unused import 2023-07-31 19:14:50 +02:00
055f71f4f1 custom error boundary 2023-07-31 19:14:50 +02:00
c0a424410e code refactoring 2023-07-31 19:14:50 +02:00
b5731759b0 migrated from redux to recoil 2023-07-31 19:14:50 +02:00
Marco
8327d1e94c Download REST API endpoints (#72)
* backend and frontend hotfixes, see message

Improved rendering on the frontend by cutting unecessary useStates.
Backend side, downloads now auto resume even on application kill.

* download rest api endpoints, general code refactor

* download request json mappings
2023-07-31 08:30:09 +02:00
Marco
68c829c40e 10 playlist download (#71)
* leveraging message queue for playlist entries DL

* playlist support implemented

It's a little bit slow but solid enough :D
2023-07-28 11:44:38 +02:00
d4f656fd87 code refactoring 2023-07-26 16:21:10 +02:00
Alexandro
a4d586a3a0 Added Polish language (#68)
* Update i18n.yaml

Added Russian language

* Update settingsSlice.ts

* Update i18n.yaml

* Update Settings.tsx

* Update i18n.yaml, Added Polish language

* Update settingsSlice.ts, added Polish language

* Update Settings.tsx, added Polish language
2023-07-26 15:55:03 +02:00
e1510d28d2 dropped fiber for std http + gorilla websocket
Session serialization will use gob encoding instead of json.
Binary size will likely be reduced.
General backend code refactoring.
2023-07-26 11:48:54 +02:00
82b22db7ae code refactor 2023-07-26 09:51:00 +02:00
deluxghost
49ded2e0f6 Update chinese (#67) 2023-07-19 07:56:55 +02:00
Alexandro
58f0e67aac Added Ukrainian language (#63)
* Update i18n.yaml

Added Russian language

* Update settingsSlice.ts

* Update i18n.yaml

* Update Settings.tsx
2023-07-08 17:50:06 +02:00
Alexandro
00c6e5aaf2 Update i18n.yaml (#61)
Added Russian language
2023-07-07 07:42:57 +02:00
Marco
3ded768a6f 10 feat download queue (#59)
* testing message queue

* better mq syncronisation

* major code refactoring, concern separation.

* bugfix

* code refactoring

* queuesize configurable via flags

* code refactoring

* comments

* code refactoring, updated readme
2023-06-26 11:27:15 +02:00
dd753c5f26 code refactoring 2023-06-24 15:02:47 +02:00
2c9d4b0a9b bugfix 2023-06-23 17:46:47 +02:00
3067cee08c code refactoring, fixed wrong jwt expire time 2023-06-23 15:18:40 +02:00
7d510fd2d4 code refactoring, deps bump 2023-06-23 14:49:58 +02:00
12300d43c5 copy url to clipboard, code refactoring 2023-06-23 11:58:11 +02:00
53045be65c code refactoring 2023-06-23 11:46:44 +02:00
765b36cc98 code refactoring 2023-06-23 11:41:55 +02:00
e9df173aef removed dead code 2023-06-23 11:02:08 +02:00
110 changed files with 7441 additions and 2335 deletions

View File

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

View File

@@ -1,28 +0,0 @@
name: Docker Image CI (Dockerhub)
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
schedule:
- cron: '0 1 * * *'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Login to Docker Hub
env:
DOCKER_USER: ${{secrets.DOCKER_HUB_USERNAME}}
DOCKER_PASSWORD: ${{secrets.DOCKER_HUB_PASSWORD}}
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
- name: Install buildx
uses: crazy-max/ghaction-docker-buildx@v1
with:
version: latest
- name: Build the Docker image
run: docker buildx build . --file Dockerfile --tag ${{secrets.DOCKER_HUB_USERNAME}}/yt-dlp-webui:latest --push --platform linux/amd64,linux/arm/v7,linux/arm64

View File

@@ -1,31 +1,14 @@
name: Docker (ghcr.io) name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on: on:
release: release:
branches: [ master ] types: [published]
tags: [ 'v*.*.*' ]
push: push:
branches: [ master ] branches: [ master ]
pull_request:
branches: [ master ]
schedule: schedule:
- cron : '0 1 * * 0' - cron : '0 1 * * 0'
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -33,65 +16,67 @@ jobs:
# This is used to complete the identity challenge # This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs. # with sigstore/fulcio when running outside of PRs.
id-token: write id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer # https://github.com/sigstore/cosign-installer
- name: Install cosign - name: Install cosign
if: github.event_name != 'pull_request' # v3.1.2
uses: sigstore/cosign-installer@1e95c1de343b5b0c23352d6417ee3e48d5bcd422 uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19
with: with:
cosign-release: 'v1.13.1' cosign-release: 'v1.13.6'
- name: Set up QEMU for ARM emulation
# Workaround: https://github.com/docker/build-push-action/issues/461 # v2.2.0
- name: Set up QEMU uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7
uses: docker/setup-qemu-action@v1
- name: Setup Docker buildx
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with: with:
registry: ${{ env.REGISTRY }} platforms: all
- name: Set up Docker Buildx
# 2.10.0
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55
- name: Login to Docker Hub
# 2.2.0
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GHCR
# 2.2.0
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
with:
registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker - name: Generate Docker metadata
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 # v4.6.0
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: |
ghcr.io/${{ github.repository }}
docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/yt-dlp-webui
tags: |
type=raw,value=latest
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc # v4.2.1
uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9
with: with:
context: . context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true
push: ${{ github.event_name != 'pull_request' }} platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels}} labels: ${{ steps.meta.outputs.labels}}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image - name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env: env:
COSIGN_EXPERIMENTAL: "true" COSIGN_EXPERIMENTAL: "true"
# This step uses the identity token to provision an ephemeral certificate # This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance. # against the sigstore community Fulcio instance.
run: cosign sign ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} run: |
cosign sign ghcr.io/${{ github.repository }}@${{ steps.build-and-push.outputs.digest }}
cosign sign docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/yt-dlp-webui@${{ steps.build-and-push.outputs.digest }}

16
.github/workflows/test-container.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Docker Image CI
on:
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build the Docker image
run: docker build . --file Dockerfile --tag my-image-name:$(date +%s)

10
.gitignore vendored
View File

@@ -1,11 +1,6 @@
.parcel-cache
dist dist
package-lock.json
pnpm-lock.yaml
.pnpm-debug.log .pnpm-debug.log
node_modules node_modules
src/server/core/*.exe
src/server/core/yt-dlp
.env .env
*.mp4 *.mp4
*.ytdl *.ytdl
@@ -16,3 +11,8 @@ downloads
build/ build/
yt-dlp-webui yt-dlp-webui
session.dat session.dat
config.yml
cookies.txt
__debug*
ui/
.idea

10
.vscode/launch.json vendored
View File

@@ -4,13 +4,19 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "go",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "main.go"
},
{ {
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"name": "Launch Chrome against localhost", "name": "Launch Chrome against localhost",
"url": "http://localhost:1234", "url": "http://localhost:5173",
"webRoot": "${workspaceFolder}", "webRoot": "${workspaceFolder}",
"breakOnLoad": true,
"sourceMapPathOverrides": { "sourceMapPathOverrides": {
"/__parcel_source_root/*": "${webRoot}/*" "/__parcel_source_root/*": "${webRoot}/*"
} }

View File

@@ -1,33 +1,42 @@
FROM golang:1.20-alpine AS build # Node (pnpm) ------------------------------------------------------------------
# folder structure FROM node:20-slim AS ui
WORKDIR /usr/src/yt-dlp-webui ENV PNPM_HOME="/pnpm"
# install core dependencies ENV PATH="$PNPM_HOME:$PATH"
RUN apk update && \ RUN corepack enable
apk add nodejs npm go COPY . /usr/src/yt-dlp-webui
# copia la salsa
COPY . .
# build frontend
WORKDIR /usr/src/yt-dlp-webui/frontend
RUN npm install
RUN npm run build
# build backend + incubator
WORKDIR /usr/src/yt-dlp-webui
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# but here yes :) WORKDIR /usr/src/yt-dlp-webui/frontend
RUN rm -rf node_modules
RUN pnpm install
RUN pnpm run build
# -----------------------------------------------------------------------------
# Go --------------------------------------------------------------------------
FROM golang AS build
WORKDIR /usr/src/yt-dlp-webui
COPY . .
COPY --from=ui /usr/src/yt-dlp-webui/frontend /usr/src/yt-dlp-webui/frontend
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# -----------------------------------------------------------------------------
# dependencies ----------------------------------------------------------------
FROM alpine:edge FROM alpine:edge
WORKDIR /downloads VOLUME /downloads /config
VOLUME /downloads
WORKDIR /app WORKDIR /app
RUN apk update && \ RUN apk update && \
apk add psmisc ffmpeg yt-dlp apk add psmisc ffmpeg yt-dlp --no-cache
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret ENV JWT_SECRET=secret
EXPOSE 3033 EXPOSE 3033
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads" ] ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]

View File

@@ -6,11 +6,11 @@ all:
CGO_ENABLED=0 go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
multiarch: multiarch:
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -o yt-dlp-webui_linux-arm main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o yt-dlp-webui_linux-arm64 main.go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o yt-dlp-webui_linux-amd64 main.go
mkdir -p build mkdir -p build
mv yt-dlp-webui* build CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go
CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv6 main.go
CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv7 main.go
clean: clean:
rm -rf build rm -rf build

103
README.md
View File

@@ -1,3 +1,8 @@
> [!IMPORTANT]
> Major frontend refactoring in progress.
> I won't add features or fix minor issues until completition.
---
# yt-dlp Web UI # yt-dlp Web UI
A not so terrible web ui for yt-dlp. A not so terrible web ui for yt-dlp.
@@ -15,14 +20,15 @@ The bottleneck remains yt-dlp startup time.
docker pull marcobaobao/yt-dlp-webui docker pull marcobaobao/yt-dlp-webui
``` ```
```sh ```sh
# latest stable # latest dev
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
# latest dev version
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
``` ```
![](https://i.ibb.co/RCpfg7q/image.png) [app.webm](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964)
![](https://i.ibb.co/N2749CD/image.png)
![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/a32fbdaa-b033-4aed-b914-a66701ace0ce)
![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/782c559a-f552-40be-a6fd-10e22f38e85d)
### Integrated File browser ### Integrated File browser
Stream or download your content, easily. Stream or download your content, easily.
@@ -68,25 +74,17 @@ The currently avaible settings are:
- Override the output filename - Override the output filename
- Override the output path - Override the output path
- Pass custom yt-dlp arguments safely - Pass custom yt-dlp arguments safely
- Download queue (limit concurrent downloads)
![](https://i.ibb.co/YdBVcgc/image.png) ![](https://i.ibb.co/YdBVcgc/image.png)
![](https://i.ibb.co/Sf102b1/image.png) ![](https://i.ibb.co/Sf102b1/image.png)
## Format selection ## Format selection
![fs1](https://i.ibb.co/8dgS6ym/image.png)
This feature is disabled by default as this intended to be used to retrieve the best quality automatically. This feature is disabled by default as this intended to be used to retrieve the best quality automatically.
To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag! To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag!
Future releases will have:
- ~~Multi download~~ *done*
- ~~Exctract audio~~ *done*
- ~~Format selection~~ *done*
- Download archive
- ~~ARM Build~~ *done available through ghcr.io*
## Troubleshooting ## Troubleshooting
- **It says that it isn't connected/ip in the header is not defined.** - **It says that it isn't connected/ip in the header is not defined.**
- You must set the server ip address in the settings section (gear icon). - You must set the server ip address in the settings section (gear icon).
@@ -95,7 +93,6 @@ Future releases will have:
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation ## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
```sh ```sh
# recomended for ARM and x86 devices
docker pull marcobaobao/yt-dlp-webui docker pull marcobaobao/yt-dlp-webui
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
``` ```
@@ -105,6 +102,12 @@ Or with docker but building the container manually.
```sh ```sh
docker build -t yt-dlp-webui . docker build -t yt-dlp-webui .
docker run -d -p 3033:3033 -v <your dir>:/downloads yt-dlp-webui docker run -d -p 3033:3033 -v <your dir>:/downloads yt-dlp-webui
docker run -d -p 3033:3033 \
-v <your dir>:/downloads \
-v <your dir>:/config \ # optional
yt-dlp-webui
``` ```
If you opt to add RPC authentication... If you opt to add RPC authentication...
@@ -113,9 +116,23 @@ docker run -d \
-p 3033:3033 \ -p 3033:3033 \
-e JWT_SECRET randomsecret -e JWT_SECRET randomsecret
-v /path/to/downloads:/downloads \ -v /path/to/downloads:/downloads \
-v /path/for/config:/config \ # optional
marcobaobao/yt-dlp-webui \ marcobaobao/yt-dlp-webui \
--auth \ --auth \
--secret your_rpc_secret --user your_username \
--pass your_pass
```
If you wish for limiting the download queue size...
e.g. limiting max 2 concurrent download.
```sh
docker run -d \
-p 3033:3033 \
-e JWT_SECRET randomsecret
-v /path/to/downloads:/downloads \
marcobaobao/yt-dlp-webui \
--qs 2
``` ```
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation ## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
@@ -134,6 +151,29 @@ yt-dlp-webui --out /home/user/downloads --driver /opt/soemdir/yt-dlp
yt-dlp-webui --conf /home/user/.config/yt-dlp-webui.conf yt-dlp-webui --conf /home/user/.config/yt-dlp-webui.conf
``` ```
### Arguments
```sh
Usage yt-dlp-webui:
-auth
Enable RPC authentication
-conf string
Config file path
-driver string
yt-dlp executable path (default "yt-dlp")
-out string
Where files will be saved (default ".")
-host string
Host where server will listen at (default "0.0.0.0")
-port int
Port where server will listen at (default 3033)
-qs int
Download queue size (default 8)
-user string
Username required for auth
-pass string
Password required for auth
```
### Config file ### Config file
By running `yt-dlp-webui` in standalone mode you have the ability to also specify a config file. By running `yt-dlp-webui` in standalone mode you have the ability to also specify a config file.
The config file **will overwrite what have been passed as cli argument**. The config file **will overwrite what have been passed as cli argument**.
@@ -142,13 +182,31 @@ The config file **will overwrite what have been passed as cli argument**.
# Simple configuration file for yt-dlp webui # Simple configuration file for yt-dlp webui
--- ---
# Host where server will listen at (default: "0.0.0.0")
#host: 0.0.0.0
# Port where server will listen at (default: 3033)
port: 8989 port: 8989
# Directory where downloaded files will be stored (default: ".")
downloadPath: /home/ren/archive downloadPath: /home/ren/archive
# [optional] Enable RPC authentication (requires username and password)
require_auth: true
username: my_username
password: my_random_secret
# [optional] The download queue size (default: 8)
queue_size: 4
# [optional] Full path to the yt-dlp (default: "yt-dlp")
downloaderPath: /usr/local/bin/yt-dlp downloaderPath: /usr/local/bin/yt-dlp
# Optional settings # [optional] Directory where the log file will be stored (default: ".")
require_auth: true #log_path: .
rpc_secret: my_random_secret
# [optional] Directory where the session database file will be stored (default: ".")
#session_file_path: .
``` ```
### Systemd integration ### Systemd integration
@@ -202,12 +260,5 @@ Just as an overview, these are the available methods:
For more information open an issue on GitHub and I will provide more info ASAP. For more information open an issue on GitHub and I will provide more info ASAP.
## FAQ
- **Will it availabe for Raspberry Pi/ generic ARM devices?**
- Yes, it's cross platform :)
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
- **Why the docker image is so heavy?**
- Originally it was 1.8GB circa, now it has been slimmed to ~340MB compressed. This is due to the fact that it encapsule a basic Alpine linux image + FFmpeg + Node.js + Python3 + yt-dlp.
- **Update**: Since Golang migration and Multi-Stage builds the Docker image is now 75MB circa. A reduction of over 400% in size :D.
## What yt-dlp-webui is not ## What yt-dlp-webui is not
`yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS. `yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS.

5
examples/Caddyfile Normal file
View File

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

37
examples/nginx.conf Normal file
View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "2.0.7", "version": "2.10.0",
"description": "Frontend compontent of yt-dlp-webui", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -8,30 +8,31 @@
}, },
"author": "marcopeocchi", "author": "marcopeocchi",
"license": "MPL-2.0", "license": "MPL-2.0",
"private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.0", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.11.16", "@fontsource/roboto": "^5.0.8",
"@mui/material": "^5.13.2", "@fontsource/roboto-mono": "^5.0.16",
"@reduxjs/toolkit": "^1.9.5", "@mui/icons-material": "^5.15.4",
"fp-ts": "^2.16.0", "@mui/material": "^5.15.4",
"fp-ts": "^2.16.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^8.0.5", "react-router-dom": "^6.21.2",
"react-router-dom": "^6.11.2", "recoil": "^0.7.7",
"rxjs": "^7.8.1", "rxjs": "^7.8.1"
"uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/node": "^20.2.4", "@types/node": "^20.11.4",
"@types/react": "^18.2.7", "@types/react": "^18.2.48",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.2.18",
"@types/react-helmet": "^6.1.11",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/uuid": "^9.0.1", "@vitejs/plugin-react-swc": "^3.6.0",
"@vitejs/plugin-react": "^4.0.0", "typescript": "^5.4.3",
"buffer": "^6.0.3", "vite": "^5.2.6",
"typescript": "^5.0.4", "million": "^3.0.6"
"vite": "^4.3.8"
} }
} }

1980
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,11 @@
import { Provider } from 'react-redux'
import { RouterProvider } from 'react-router-dom' import { RouterProvider } from 'react-router-dom'
import { RecoilRoot } from 'recoil'
import { router } from './router' import { router } from './router'
import { store } from './stores/store'
export function App() { export function App() {
return ( return (
<Provider store={store}> <RecoilRoot>
<RouterProvider router={router} /> <RouterProvider router={router} />
</Provider> </RecoilRoot>
) )
} }

View File

@@ -1,15 +1,11 @@
import { ThemeProvider } from '@emotion/react' import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard' import Dashboard from '@mui/icons-material/Dashboard'
import Menu from '@mui/icons-material/Menu' import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings' import SettingsIcon from '@mui/icons-material/Settings'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet' import TerminalIcon from '@mui/icons-material/Terminal'
import Storage from '@mui/icons-material/Storage'
import { Box, createTheme } from '@mui/material' import { Box, createTheme } from '@mui/material'
import DownloadIcon from '@mui/icons-material/Download'
import CssBaseline from '@mui/material/CssBaseline' import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'
@@ -19,27 +15,24 @@ import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors' import { grey } from '@mui/material/colors'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet } from 'react-router-dom'
import { RootState } from './stores/store' import { useRecoilValue } from 'recoil'
import { settingsState } from './atoms/settings'
import AppBar from './components/AppBar' import AppBar from './components/AppBar'
import Drawer from './components/Drawer' import Drawer from './components/Drawer'
import Footer from './components/Footer'
import Logout from './components/Logout' import Logout from './components/Logout'
import { formatGiB } from './utils' import SocketSubscriber from './components/SocketSubscriber'
import ThemeToggler from './components/ThemeToggler' import ThemeToggler from './components/ThemeToggler'
import { useI18n } from './hooks/useI18n'
import Toaster from './providers/ToasterProvider'
export default function Layout() { export default function Layout() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const settings = useSelector((state: RootState) => state.settings) const settings = useRecoilValue(settingsState)
const status = useSelector((state: RootState) => state.status)
const mode = settings.theme const mode = settings.theme
const theme = useMemo(() => const theme = useMemo(() =>
@@ -53,12 +46,13 @@ export default function Layout() {
}), [settings.theme] }), [settings.theme]
) )
const toggleDrawer = () => { const toggleDrawer = () => setOpen(state => !state)
setOpen(state => !state)
} const { i18n } = useI18n()
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<SocketSubscriber />
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex' }}>
<CssBaseline /> <CssBaseline />
<AppBar position="absolute" open={open}> <AppBar position="absolute" open={open}>
@@ -82,28 +76,8 @@ export default function Layout() {
noWrap noWrap
sx={{ flexGrow: 1 }} sx={{ flexGrow: 1 }}
> >
yt-dlp WebUI {settings.appTitle}
</Typography> </Typography>
{
status.freeSpace ?
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<Storage />
<span>&nbsp;{formatGiB(status.freeSpace)}&nbsp;</span>
</div>
: null
}
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<SettingsEthernet />
<span>&nbsp;{status.connected ? settings.serverAddr : 'not connected'}</span>
</div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer variant="permanent" open={open}> <Drawer variant="permanent" open={open}>
@@ -127,11 +101,11 @@ export default function Layout() {
color: mode === 'dark' ? '#ffffff' : '#000000DE' color: mode === 'dark' ? '#ffffff' : '#000000DE'
} }
}> }>
<ListItemButton disabled={status.downloading}> <ListItemButton>
<ListItemIcon> <ListItemIcon>
<Dashboard /> <Dashboard />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Home" /> <ListItemText primary={i18n.t('homeButtonLabel')} />
</ListItemButton> </ListItemButton>
</Link> </Link>
<Link to={'/archive'} style={ <Link to={'/archive'} style={
@@ -140,11 +114,24 @@ export default function Layout() {
color: mode === 'dark' ? '#ffffff' : '#000000DE' color: mode === 'dark' ? '#ffffff' : '#000000DE'
} }
}> }>
<ListItemButton disabled={status.downloading}> <ListItemButton>
<ListItemIcon> <ListItemIcon>
<DownloadIcon /> <ArchiveIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Archive" /> <ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/log'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<TerminalIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('logsTitle')} />
</ListItemButton> </ListItemButton>
</Link> </Link>
<Link to={'/settings'} style={ <Link to={'/settings'} style={
@@ -153,11 +140,11 @@ export default function Layout() {
color: mode === 'dark' ? '#ffffff' : '#000000DE' color: mode === 'dark' ? '#ffffff' : '#000000DE'
} }
}> }>
<ListItemButton disabled={status.downloading}> <ListItemButton>
<ListItemIcon> <ListItemIcon>
<SettingsIcon /> <SettingsIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Settings" /> <ListItemText primary={i18n.t('settingsButtonLabel')} />
</ListItemButton> </ListItemButton>
</Link> </Link>
<ThemeToggler /> <ThemeToggler />
@@ -176,6 +163,8 @@ export default function Layout() {
<Outlet /> <Outlet />
</Box> </Box>
</Box> </Box>
<Footer />
<Toaster />
</ThemeProvider> </ThemeProvider>
) )
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,7 +1,7 @@
--- ---
languages: languages:
english: english:
urlInput: YouTube or other supported service video URL urlInput: Video URL (one per line)
statusTitle: Status statusTitle: Status
statusReady: Ready statusReady: Ready
selectFormatButton: Select format selectFormatButton: Select format
@@ -29,8 +29,128 @@ languages:
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server rpcConnErr: Error while conencting to RPC server
splashText: No active downloads
archiveTitle: Archive
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
restartAppMessage: Needs a page reload to take effect
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
german:
urlInput: Video URL
statusTitle: Status
statusReady: Bereit
selectFormatButton: Format auswählen
startButton: Start
abortAllButton: Alle Abbrechen
updateBinButton: yt-dlp Binärdatei aktualisieren
darkThemeButton: Dunkel Modus
lightThemeButton: Hell Modus
settingsAnchor: Einstellungen
serverAddressTitle: Server Adresse
serverPortTitle: Port
extractAudioCheckbox: Audio extrahieren
noMTimeCheckbox: Datei-Änderungszeitpunkt nicht festlegen
bgReminder: Sobald Sie diese Seite schließen, wird der Download im Hintergrund fortgesetzt.
toastConnected: 'Verbunden mit '
toastUpdated: yt-dlp Binärdatei aktualisiert!
formatSelectionEnabler: Video/Audio Format auswählbar
themeSelect: 'Modus'
languageSelect: 'Sprache'
overridesAnchor: Überschreibungen
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
customFilename: Custom filemame (leave blank to use default)
customPath: Benutzerdefinierter Pfad
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
customArgsInput: Benutzerdefinierte yt-dlp Argumente
rpcConnErr: Fehler beim Verbinden mit RPC Server
splashText: Keine aktiven Downloads
archiveTitle: Archiv
clipboardAction: URL in Zwischenablage kopiert
playlistCheckbox: Playlist herunterladen (es wird einige Zeit dauern, nach dem Absenden können Sie dieses Fenster schließen)
restartAppMessage: Erfordert ein Neuladen der Seite, um wirksam zu werden
servedFromReverseProxyCheckbox: Ist hinter einem Reverse Proxy Unterordner
newDownloadButton: Neuer Download
homeButtonLabel: Home
archiveButtonLabel: Archiv
settingsButtonLabel: Einstellungen
rpcAuthenticationLabel: RPC Authentifizierung
themeTogglerLabel: Modus Umschalter
loadingLabel: Lädt...
appTitle: App Titel
savedTemplates: Gespeicherte Vorlage
templatesEditor: Vorlagen Bearbeiter
templatesEditorNameLabel: Vorlagen Name
templatesEditorContentLabel: Vorlagen Inhalt
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut
statusReady: Prêt
selectFormatButton: Sélectionner le format
startButton: Démarrer
abortAllButton: Tout arrêter
updateBinButton: Mettre à jour l'exécutable yt-dlp
darkThemeButton: Thème sombre
lightThemeButton: Thème clair
settingsAnchor: Paramètres
serverAddressTitle: Adresse du serveur
serverPortTitle: Port
extractAudioCheckbox: Extraire l'audio
noMTimeCheckbox: Ne pas définir le temps de modification du fichier
bgReminder: Une fois que vous aurez fermé cette page, le téléchargement continuera en arrière-plan.
toastConnected: 'Connecté à '
toastUpdated: L'exécutable yt-dlp a été mis à jour !
formatSelectionEnabler: Activer la sélection des formats vidéo/audio
themeSelect: 'Thème'
languageSelect: 'Langue'
overridesAnchor: Remplacer
pathOverrideOption: Activer le remplacement du chemin de sortie
filenameOverrideOption: Activer le remplacement du nom du fichier de sortie
customFilename: Nom de fichier personnalisé (laisser vide pour utiliser le nom par défaut)
customPath: Chemin personnalisé
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
customArgsInput: Arguments yt-dlp personnalisés
rpcConnErr: Erreur lors de la connexion au serveur RPC
splashText: Aucun téléchargement actif
archiveTitle: Archive
clipboardAction: URL copiée dans le presse-papiers
playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée)
restartAppMessage: Nécessite un rechargement de la page pour prendre effet
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
notConnectedText: not connected
settingsLabel: Settings
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: Nom de l'application
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
italian: italian:
urlInput: URL di YouTube o di qualsiasi altro servizio supportato urlInput: URL Video (uno per linea)
statusTitle: Stato statusTitle: Stato
startButton: Inizia startButton: Inizia
statusReady: Pronto statusReady: Pronto
@@ -57,11 +177,32 @@ languages:
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error nella connessione al server RPC rpcConnErr: Error nella connessione al server RPC
splashText: Nessun download attivo
archiveTitle: Archivio
clipboardAction: URL copiato negli appunti
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
servedFromReverseProxyCheckbox: Is behind a reverse proxy
newDownloadButton: Nuovo download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: Titolo applicazione
savedTemplates: Template salvati
templatesEditor: Editor template
templatesEditorNameLabel: Nome template
templatesEditorContentLabel: Contentunto template
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
chinese: chinese:
urlInput: YouTube 或其他受支持服务的视频网址 urlInput: 视频 URL
statusTitle: 状态 statusTitle: 状态
startButton: 开始
statusReady: 就绪 statusReady: 就绪
selectFormatButton: 选择格式
startButton: 开始
abortAllButton: 全部中止 abortAllButton: 全部中止
updateBinButton: 更新 yt-dlp 可执行文件 updateBinButton: 更新 yt-dlp 可执行文件
darkThemeButton: 黑暗主题 darkThemeButton: 黑暗主题
@@ -84,7 +225,27 @@ languages:
customPath: 自定义路径 customPath: 自定义路径
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大) customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
customArgsInput: 自定义 yt-dlp 参数 customArgsInput: 自定义 yt-dlp 参数
rpcConnErr: Error while conencting to RPC server rpcConnErr: 连接 RPC 服务器发生错误
splashText: 没有正在进行的下载
archiveTitle: 归档
clipboardAction: 复制 URL 到剪贴板
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
restartAppMessage: 需要刷新页面才能生效
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
newDownloadButton: 新下载
homeButtonLabel: 主页
archiveButtonLabel: 归档
settingsButtonLabel: 设置
rpcAuthenticationLabel: RPC 身份验证
themeTogglerLabel: 主题切换
loadingLabel: 正在加载…
appTitle: App 标题
savedTemplates: 保存模板
templatesEditor: 模板编辑器
templatesEditorNameLabel: 模板名称
templatesEditorContentLabel: 模板内容
logsTitle: '日志'
awaitingLogs: '正在等待日志…'
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -113,34 +274,72 @@ languages:
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad) customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
customArgsInput: Argumentos yt-dlp personalizados customArgsInput: Argumentos yt-dlp personalizados
rpcConnErr: Error al conectarse al servidor RPC rpcConnErr: Error al conectarse al servidor RPC
splashText: No active downloads
archiveTitle: Archive
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
russian: russian:
urlInput: YouTube or other supported service video url urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Status statusTitle: Статус
startButton: Start startButton: Начать
statusReady: Ready statusReady: Готово
abortAllButton: Abort All abortAllButton: Прервать все
updateBinButton: Update yt-dlp binary updateBinButton: Обновить бинарный файл yt-dlp
darkThemeButton: Dark theme darkThemeButton: Темная тема
lightThemeButton: Light theme lightThemeButton: Светлая тема
settingsAnchor: Settings settingsAnchor: Настройки
serverAddressTitle: Server address serverAddressTitle: Адрес сервера
serverPortTitle: Port serverPortTitle: Порт
extractAudioCheckbox: Extract audio extractAudioCheckbox: Извлечь аудио
noMTimeCheckbox: Don't set file modification time noMTimeCheckbox: Не устанавливать время модификации файла
bgReminder: Once you close this page the download will continue in the background. bgReminder: Как только вы закроете эту страницу, загрузка продолжится в фоновом режиме.
toastConnected: 'Connected to ' toastConnected: 'Подключен к '
toastUpdated: Updated yt-dlp binary! toastUpdated: Бинарный файл yt-dlp обновлен!
formatSelectionEnabler: Enable video/audio formats selection formatSelectionEnabler: Активировать выбор видео/аудио форматов
themeSelect: 'Theme' themeSelect: 'Тема'
languageSelect: 'Language' languageSelect: 'Язык'
overridesAnchor: Overrides overridesAnchor: Переопределить
pathOverrideOption: Enable output path overriding pathOverrideOption: Активировать переопределение выходного пути
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: Активировать переопределение имени выходного файла
customFilename: Custom filemame (leave blank to use default) customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию)
customPath: Custom path customPath: Задать путь
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность)
customArgsInput: Custom yt-dlp arguments customArgsInput: Пользовательские аргументы yt-dlp
rpcConnErr: Error while conencting to RPC server rpcConnErr: Ошибка при подключении к серверу RPC
splashText: Нет активных загрузок
archiveTitle: Архив
clipboardAction: URL скопирован в буфер обмена
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -169,6 +368,25 @@ languages:
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server rpcConnErr: Error while conencting to RPC server
splashText: No active downloads
archiveTitle: Archive
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -198,6 +416,25 @@ languages:
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合) customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
customArgsInput: yt-dlpのオプション customArgsInput: yt-dlpのオプション
rpcConnErr: Error while conencting to RPC server rpcConnErr: Error while conencting to RPC server
splashText: No active downloads
archiveTitle: Archive
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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
@@ -226,3 +463,116 @@ languages:
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat) customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
customArgsInput: Arguments yt-dlp personalitzats customArgsInput: Arguments yt-dlp personalitzats
rpcConnErr: Error en connectar-se al servidor RPC rpcConnErr: Error en connectar-se al servidor RPC
splashText: No active downloads
archiveTitle: Archive
clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус
startButton: Почати
statusReady: Готово
abortAllButton: Перервати все
updateBinButton: Оновити бінарний файл yt-dlp
darkThemeButton: Темна тема
lightThemeButton: Світла тема
settingsAnchor: Налаштування
serverAddressTitle: Адреса сервера
serverPortTitle: Порт
extractAudioCheckbox: Витягти аудіо
noMTimeCheckbox: Не встановлювати час модифікації файлу
bgReminder: Як тільки ви закриєте цю сторінку, завантаження продовжиться у фоновому режимі.
toastConnected: 'Підключений до '
toastUpdated: Бінарний файл yt-dlp оновлено!
formatSelectionEnabler: Активувати вибір відео/аудіо форматів
themeSelect: 'Тема'
languageSelect: 'Мова'
overridesAnchor: Перевизначити
pathOverrideOption: Активувати перевизначення вихідного шляху
filenameOverrideOption: Активувати перевизначення імені вихідного файлу
customFilename: Введіть ім'я файлу (залишіть порожнім, щоб використовувати значення за замовчуванням)
customPath: Задати шлях
customArgs: Включити аргументи, що настроюються yt-dlp (велика сила = велика відповідальність)
customArgsInput: Користувальницькі аргументи yt-dlp
rpcConnErr: Помилка при підключенні до сервера RPC
splashText: Немає активних завантажень
archiveTitle: Архів
clipboardAction: URL скопійовано в буфер обміну
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status
startButton: Początek
statusReady: Gotowy
abortAllButton: Anuluj wszystko
updateBinButton: Zaktualizuj plik binarny yt-dlp
darkThemeButton: Ciemny motyw
lightThemeButton: Światło motyw
settingsAnchor: Ustawienia
serverAddressTitle: Adres serwera
serverPortTitle: Port
extractAudioCheckbox: Wyodrębnij dźwięk
noMTimeCheckbox: Nie ustawiaj czasu modyfikacji pliku
bgReminder: Po zamknięciu tej strony pobieranie będzie kontynuowane w tle.
toastConnected: 'Połączony z '
toastUpdated: Zaktualizowano plik binarny yt-dlp!
formatSelectionEnabler: Aktywuj wybór formatów wideo/audio
themeSelect: 'Motyw'
languageSelect: 'Język'
overridesAnchor: Przedefiniuj
pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej
filenameOverrideOption: Aktywuj zastępowanie nazwy pliku źródłowego
customFilename: Wprowadź nazwę pliku (pozostaw puste, aby użyć nazwy domyślnej)
customPath: Ustaw ścieżkę
customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność)
customArgsInput: Niestandardowe argumenty yt-dlp
rpcConnErr: Wystąpił błąd podczas łączenia z serwerem RPC
splashText: Brak aktywnych pobrań
archiveTitle: Archiwum
clipboardAction: Adres URL zostanie skopiowany do schowka
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { selector } from 'recoil'
import I18nBuilder from '../lib/intl'
import { languageState } from './settings'
export const i18nBuilderState = selector({
key: 'i18nBuilderState',
get: ({ get }) => new I18nBuilder(get(languageState)),
dangerouslyAllowMutability: true,
})

14
frontend/src/atoms/rpc.ts Normal file
View File

@@ -0,0 +1,14 @@
import { selector } from 'recoil'
import { RPCClient } from '../lib/rpcClient'
import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings'
export const rpcClientState = selector({
key: 'rpcClientState',
get: ({ get }) =>
new RPCClient(
get(rpcHTTPEndpoint),
get(rpcWebSocketEndpoint),
localStorage.getItem('token') ?? ''
),
dangerouslyAllowMutability: true,
})

View File

@@ -0,0 +1,212 @@
import { atom, selector } from 'recoil'
import { prefersDarkMode } from '../utils'
export const languages = [
'english',
'chinese',
'russian',
'italian',
'spanish',
'korean',
'japanese',
'catalan',
'ukrainian',
'polish',
'german'
] as const
export type Language = (typeof languages)[number]
export type Theme = 'light' | 'dark' | 'system'
export type ThemeNarrowed = 'light' | 'dark'
export interface SettingsState {
serverAddr: string
serverPort: number
language: Language
theme: ThemeNarrowed
cliArgs: string
formatSelection: boolean
fileRenaming: boolean
pathOverriding: boolean
enableCustomArgs: boolean
listView: boolean
servedFromReverseProxy: boolean
appTitle: string
}
export const languageState = atom<Language>({
key: 'languageState',
default: localStorage.getItem('language') as Language || 'english',
effects: [
({ onSet }) =>
onSet(l => localStorage.setItem('language', l.toString()))
]
})
export const themeState = atom<Theme>({
key: 'themeStateState',
default: localStorage.getItem('theme') as Theme || 'system',
effects: [
({ onSet }) =>
onSet(l => localStorage.setItem('theme', l.toString()))
]
})
export const serverAddressState = atom<string>({
key: 'serverAddressState',
default: localStorage.getItem('server-addr') || window.location.hostname,
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('server-addr', a.toString()))
]
})
export const serverPortState = atom<number>({
key: 'serverPortState',
default: Number(localStorage.getItem('server-port')) ||
Number(window.location.port),
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('server-port', a.toString()))
]
})
export const latestCliArgumentsState = atom<string>({
key: 'latestCliArgumentsState',
default: localStorage.getItem('cli-args') || '--no-mtime',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('cli-args', a.toString()))
]
})
export const formatSelectionState = atom({
key: 'formatSelectionState',
default: localStorage.getItem('format-selection') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('format-selection', a.toString()))
]
})
export const fileRenamingState = atom({
key: 'fileRenamingState',
default: localStorage.getItem('file-renaming') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('file-renaming', a.toString()))
]
})
export const pathOverridingState = atom({
key: 'pathOverridingState',
default: localStorage.getItem('path-overriding') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('path-overriding', a.toString()))
]
})
export const enableCustomArgsState = atom({
key: 'enableCustomArgsState',
default: localStorage.getItem('enable-custom-args') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('enable-custom-args', a.toString()))
]
})
export const listViewState = atom({
key: 'listViewState',
default: localStorage.getItem('listview') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('listview', a.toString()))
]
})
export const servedFromReverseProxyState = atom({
key: 'servedFromReverseProxyState',
default: localStorage.getItem('reverseProxy') === "true" || window.location.port == "",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxy', a.toString()))
]
})
export const appTitleState = atom({
key: 'appTitleState',
default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('appTitle', a.toString()))
]
})
export const serverAddressAndPortState = selector({
key: 'serverAddressAndPortState',
get: ({ get }) => get(servedFromReverseProxyState)
? `${get(serverAddressState)}`
: `${get(serverAddressState)}:${get(serverPortState)}`
})
export const serverURL = selector({
key: 'serverURL',
get: ({ get }) =>
`${window.location.protocol}//${get(serverAddressAndPortState)}`
})
export const rpcWebSocketEndpoint = selector({
key: 'rpcWebSocketEndpoint',
get: ({ get }) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
}
})
export const rpcHTTPEndpoint = selector({
key: 'rpcHTTPEndpoint',
get: ({ get }) => {
const proto = window.location.protocol
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
}
})
export const cookiesState = atom({
key: 'cookiesState',
default: localStorage.getItem('yt-dlp-cookies') ?? '',
effects: [
({ onSet }) =>
onSet(c => localStorage.setItem('yt-dlp-cookies', c))
]
})
export const themeSelector = selector<ThemeNarrowed>({
key: 'themeSelector',
get: ({ get }) => {
const theme = get(themeState)
if ((theme === 'system' && prefersDarkMode()) || theme === 'dark') {
return 'dark'
}
return 'light'
}
})
export const settingsState = selector<SettingsState>({
key: 'settingsState',
get: ({ get }) => ({
serverAddr: get(serverAddressState),
serverPort: get(serverPortState),
language: get(languageState),
theme: get(themeSelector),
cliArgs: get(latestCliArgumentsState),
formatSelection: get(formatSelectionState),
fileRenaming: get(fileRenamingState),
pathOverriding: get(pathOverridingState),
enableCustomArgs: get(enableCustomArgsState),
listView: get(listViewState),
servedFromReverseProxy: get(servedFromReverseProxyState),
appTitle: get(appTitleState)
})
})

View File

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

View File

@@ -0,0 +1,15 @@
import { AlertColor } from '@mui/material'
import { atom } from 'recoil'
export type Toast = {
open: boolean,
message: string
autoClose: boolean
createdAt: number,
severity?: AlertColor
}
export const toastListState = atom<Toast[]>({
key: 'toastListState',
default: [],
})

20
frontend/src/atoms/ui.ts Normal file
View File

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

View File

@@ -1,37 +0,0 @@
import {
Card,
CardActionArea,
CardContent,
CardMedia,
Skeleton,
Typography
} from '@mui/material'
import { ellipsis } from '../utils'
type Props = {
title: string,
thumbnail: string,
url: string,
}
export function ArchiveResult({ title, thumbnail, url }: Props) {
return (
<Card>
<CardActionArea onClick={() => window.open(url)}>
{thumbnail ?
<CardMedia
component="img"
height={180}
image={thumbnail}
/> :
<Skeleton variant="rectangular" height={180} />
}
<CardContent>
<Typography gutterBottom variant="body2" component="div">
{ellipsis(title, 72)}
</Typography>
</CardContent>
</CardActionArea>
</Card>
)
}

View File

@@ -0,0 +1,157 @@
import { TextField } from '@mui/material'
import * as A from 'fp-ts/Array'
import * as E from 'fp-ts/Either'
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/lib/function'
import { useMemo } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { cookiesTemplateState } from '../atoms/downloadTemplate'
import { cookiesState, serverURL } from '../atoms/settings'
import { useSubscription } from '../hooks/observable'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
const validateCookie = (cookie: string) => pipe(
cookie,
cookie => cookie.replace(/\s\s+/g, ' '),
cookie => cookie.replaceAll('\t', ' '),
cookie => cookie.split(' '),
E.of,
E.flatMap(
E.fromPredicate(
f => f.length === 7,
() => `missing parts`
)
),
E.flatMap(
E.fromPredicate(
f => f[0].length > 0,
() => 'missing domain'
)
),
E.flatMap(
E.fromPredicate(
f => f[1] === 'TRUE' || f[1] === 'FALSE',
() => `invalid include subdomains`
)
),
E.flatMap(
E.fromPredicate(
f => f[2].length > 0,
() => 'invalid path'
)
),
E.flatMap(
E.fromPredicate(
f => f[3] === 'TRUE' || f[3] === 'FALSE',
() => 'invalid secure flag'
)
),
E.flatMap(
E.fromPredicate(
f => isFinite(Number(f[4])),
() => 'invalid expiration'
)
),
E.flatMap(
E.fromPredicate(
f => f[5].length > 0,
() => 'invalid name'
)
),
E.flatMap(
E.fromPredicate(
f => f[6].length > 0,
() => 'invalid value'
)
),
)
const CookiesTextField: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const [, setCookies] = useRecoilState(cookiesTemplateState)
const [savedCookies, setSavedCookies] = useRecoilState(cookiesState)
const { pushMessage } = useToast()
const flag = '--cookies=cookies.txt'
const cookies$ = useMemo(() => new Subject<string>(), [])
const submitCookies = (cookies: string) =>
ffetch(`${serverAddr}/api/v1/cookies`, {
method: 'POST',
body: JSON.stringify({
cookies
})
})()
const validateNetscapeCookies = (cookies: string) => pipe(
cookies,
cookies => cookies.split('\n'),
cookies => cookies.filter(f => !f.startsWith('\n')), // empty lines
cookies => cookies.filter(f => !f.startsWith('# ')), // comments
cookies => cookies.filter(Boolean), // empty lines
A.map(validateCookie),
A.mapWithIndex((i, either) => pipe(
either,
E.matchW(
(l) => pushMessage(`Error in line ${i + 1}: ${l}`, 'warning'),
() => E.isRight(either)
),
)),
A.filter(Boolean),
A.match(
() => false,
(c) => {
pushMessage(`Valid ${c.length} Netscape cookies`, 'info')
return true
}
)
)
useSubscription(
cookies$.pipe(
debounceTime(650),
distinctUntilChanged()
),
(cookies) => pipe(
cookies,
cookies => {
setSavedCookies(cookies)
return cookies
},
validateNetscapeCookies,
O.fromPredicate(f => f === true),
O.match(
() => setCookies(''),
async () => {
pipe(
await submitCookies(cookies),
E.match(
(l) => pushMessage(`${l}`, 'error'),
() => {
pushMessage(`Saved Netscape cookies`, 'success')
setCookies(flag)
}
)
)
}
)
)
)
return (
<TextField
label="Netscape Cookies"
multiline
maxRows={20}
minRows={4}
fullWidth
defaultValue={savedCookies}
onChange={(e) => cookies$.next(e.currentTarget.value)}
/>
)
}
export default CookiesTextField

View File

@@ -0,0 +1,117 @@
import EightK from '@mui/icons-material/EightK'
import FourK from '@mui/icons-material/FourK'
import Hd from '@mui/icons-material/Hd'
import Sd from '@mui/icons-material/Sd'
import {
Button,
Card,
CardActionArea,
CardActions,
CardContent,
CardMedia,
Chip,
LinearProgress,
Skeleton,
Stack,
Typography
} from '@mui/material'
import { useCallback } from 'react'
import { RPCResult } from '../types'
import { ellipsis, formatSpeedMiB, mapProcessStatus, formatSize } from '../utils'
type Props = {
download: RPCResult
onStop: () => void
onCopy: () => void
}
const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
if (!resolution) return null
if (resolution.includes('4320')) return <EightK color="primary" />
if (resolution.includes('2160')) return <FourK color="primary" />
if (resolution.includes('1080')) return <Hd color="primary" />
if (resolution.includes('720')) return <Sd color="primary" />
return null
}
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
const isCompleted = useCallback(
() => download.progress.percentage === '-1',
[download.progress.percentage]
)
const percentageToNumber = useCallback(
() => isCompleted()
? 100
: Number(download.progress.percentage.replace('%', '')),
[download.progress.percentage, isCompleted]
)
return (
<Card>
<CardActionArea onClick={() => {
navigator.clipboard.writeText(download.info.url)
onCopy()
}}>
{download.info.thumbnail !== '' ?
<CardMedia
component="img"
height={180}
image={download.info.thumbnail}
/> :
<Skeleton variant="rectangular" height={180} />
}
{download.progress.percentage ?
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted() ? "success" : "primary"}
/> :
null
}
<CardContent>
{download.info.title !== '' ?
<Typography gutterBottom variant="h6" component="div">
{ellipsis(download.info.title, 100)}
</Typography> :
<Skeleton />
}
<Stack direction="row" spacing={0.5} py={1}>
<Chip
label={
isCompleted()
? 'Completed'
: mapProcessStatus(download.progress.process_status)
}
color="primary"
size="small"
/>
<Typography>
{!isCompleted() ? download.progress.percentage : ''}
</Typography>
<Typography>
&nbsp;
{!isCompleted() ? formatSpeedMiB(download.progress.speed) : ''}
</Typography>
<Typography>
{formatSize(download.info.filesize_approx ?? 0)}
</Typography>
<Resolution resolution={download.info.resolution} />
</Stack>
</CardContent>
</CardActionArea>
<CardActions>
<Button
variant="contained"
size="small"
color="primary"
onClick={onStop}
>
{isCompleted() ? "Clear" : "Stop"}
</Button>
</CardActions>
</Card>
)
}
export default DownloadCard

View File

@@ -1,35 +1,47 @@
import { FileUpload } from '@mui/icons-material' import { FileUpload } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import { import {
Autocomplete,
Backdrop,
Box,
Button, Button,
Checkbox,
Container, Container,
FormControl, FormControl,
FormControlLabel,
Grid, Grid,
IconButton, IconButton,
InputAdornment, InputAdornment,
InputLabel,
MenuItem,
Paper, Paper,
Select,
styled,
TextField TextField
} from '@mui/material' } from '@mui/material'
import AppBar from '@mui/material/AppBar' import AppBar from '@mui/material/AppBar'
import Dialog from '@mui/material/Dialog' import Dialog from '@mui/material/Dialog'
import Slide from '@mui/material/Slide' import Slide from '@mui/material/Slide'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import { TransitionProps } from '@mui/material/transitions'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { Buffer } from 'buffer' import { TransitionProps } from '@mui/material/transitions'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' import {
import { useDispatch, useSelector } from 'react-redux' FC,
Suspense,
forwardRef,
useEffect,
useMemo,
useRef,
useState,
useTransition
} from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate'
import { latestCliArgumentsState, settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser' import { CliArguments } from '../lib/argsParser'
import I18nBuilder from '../lib/intl'
import { RPCClient } from '../lib/rpcClient'
import { RootState } from '../stores/store'
import type { DLMetadata } from '../types' import type { DLMetadata } from '../types'
import { isValidURL, toFormatArgs } from '../utils' import { isValidURL, toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions'
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
@@ -43,82 +55,83 @@ const Transition = forwardRef(function Transition(
type Props = { type Props = {
open: boolean open: boolean
onClose: () => void onClose: () => void
onDownloadStart: (url: string) => void
} }
export default function DownloadDialog({ open, onClose }: Props) { const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
// redux state const settings = useRecoilValue(settingsState)
const settings = useSelector((state: RootState) => state.settings) const isConnected = useRecoilValue(connectedState)
const status = useSelector((state: RootState) => state.status) const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
const dispatch = useDispatch() const downloadTemplate = useRecoilValue(downloadTemplateState)
const savedTemplates = useRecoilValue(savedTemplatesState)
// ephemeral state
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>() const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState('') const [pickedVideoFormat, setPickedVideoFormat] = useState('')
const [pickedAudioFormat, setPickedAudioFormat] = useState('') const [pickedAudioFormat, setPickedAudioFormat] = useState('')
const [pickedBestFormat, setPickedBestFormat] = useState('') const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useState('') const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [downloadPath, setDownloadPath] = useState(0) const [, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([])
const [fileNameOverride, setFilenameOverride] = useState('') const [downloadPath, setDownloadPath] = useState('')
const [filenameTemplate, setFilenameTemplate] = useRecoilState(
filenameTemplateState
)
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [workingUrl, setWorkingUrl] = useState('')
// memos const [isPlaylist, setIsPlaylist] = useState(false)
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort]) const argsBuilder = useMemo(() =>
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]) new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
)
const { i18n } = useI18n()
const { client } = useRPC()
// refs
const urlInputRef = useRef<HTMLInputElement>(null) const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null) const customFilenameInputRef = useRef<HTMLInputElement>(null)
// effects const [isPending, startTransition] = useTransition()
useEffect(() => {
client.directoryTree()
.then(data => {
setAvailableDownloadPaths(data.result)
})
}, [])
useEffect(() => { useEffect(() => {
setCustomArgs(localStorage.getItem('last-input-args') ?? '') setCustomArgs('')
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '') }, [open])
}, [])
/** /**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket * Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
*/ */
const sendUrl = (immediate?: string) => { const sendUrl = async (immediate?: string) => {
for (const line of url.split('\n')) {
const codes = new Array<string>() const codes = new Array<string>()
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat) if (pickedVideoFormat !== '') codes.push(pickedVideoFormat)
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat) if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat) if (pickedBestFormat !== '') codes.push(pickedBestFormat)
client.download( await new Promise(r => setTimeout(r, 10))
immediate || url || workingUrl, await client.download({
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`, url: immediate || line,
availableDownloadPaths[downloadPath] ?? '', args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
fileNameOverride pathOverride: downloadPath ?? '',
) renameTo: settings.fileRenaming ? filenameTemplate : '',
playlist: isPlaylist,
setUrl('') })
setWorkingUrl('')
setTimeout(() => { setTimeout(() => {
resetInput() resetInput()
setDownloadFormats(undefined) setDownloadFormats(undefined)
onClose() onDownloadStart(immediate || line)
}, 250) }, 250)
} }
setUrl('')
}
/** /**
* Retrive url from input and display the formats selection view * Retrive url from input and display the formats selection view
*/ */
const sendUrlFormatSelection = () => { const sendUrlFormatSelection = () => {
setWorkingUrl(url)
setUrl('') setUrl('')
setPickedAudioFormat('') setPickedAudioFormat('')
setPickedVideoFormat('') setPickedVideoFormat('')
@@ -131,45 +144,30 @@ export default function DownloadDialog({ open, onClose }: Props) {
}) })
} }
/**
* Update the url state whenever the input value changes
* @param e Input change event
*/
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value) setUrl(e.target.value)
} }
/** const handleFilenameTemplateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
* Update the filename override state whenever the input value changes setFilenameTemplate(e.target.value)
* @param e Input change event
*/
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilenameOverride(e.target.value)
localStorage.setItem('last-filename-override', e.target.value)
} }
/**
* Update the custom args state whenever the input value changes
* @param e Input change event
*/
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomArgs(e.target.value) setCustomArgs(e.target.value)
localStorage.setItem("last-input-args", e.target.value)
} }
const parseUrlListFile = (event: any) => { const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const urlList = event.target.files const files = e.currentTarget.files
const reader = new FileReader() if (!files || files.length < 1) {
reader.addEventListener('load', $event => { return
const base64 = $event.target?.result!.toString().split(',')[1] }
Buffer.from(base64!, 'base64')
.toString() const file = await files[0].text()
.trimEnd()
file
.split('\n') .split('\n')
.filter(_url => isValidURL(_url)) .filter(u => isValidURL(u))
.forEach(_url => sendUrl(_url)) .forEach(u => sendUrl(u))
})
reader.readAsDataURL(urlList[0])
} }
const resetInput = () => { const resetInput = () => {
@@ -179,20 +177,17 @@ export default function DownloadDialog({ open, onClose }: Props) {
} }
} }
/* -------------------- styled components -------------------- */
const Input = styled('input')({
display: 'none',
})
return ( return (
<div>
<Dialog <Dialog
fullScreen fullScreen
open={open} open={open}
onClose={onClose} onClose={onClose}
TransitionComponent={Transition} TransitionComponent={Transition}
> >
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isPending}
/>
<AppBar sx={{ position: 'relative' }}> <AppBar sx={{ position: 'relative' }}>
<Toolbar> <Toolbar>
<IconButton <IconButton
@@ -208,10 +203,15 @@ export default function DownloadDialog({ open, onClose }: Props) {
</Typography> </Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Container sx={{ mt: 4 }}> <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 container spacing={2}>
<Grid item xs={12}> <Grid item xs={12}>
<Paper <Paper
elevation={4}
sx={{ sx={{
p: 2, p: 2,
display: 'flex', display: 'flex',
@@ -220,18 +220,32 @@ export default function DownloadDialog({ open, onClose }: Props) {
> >
<Grid container> <Grid container>
<TextField <TextField
multiline
fullWidth fullWidth
ref={urlInputRef} ref={urlInputRef}
label={i18n.t('urlInput')} label={i18n.t('urlInput')}
variant="outlined" variant="outlined"
onChange={handleUrlChange} onChange={handleUrlChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)} disabled={
!isConnected
|| (settings.formatSelection && downloadFormats != null)
}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<label htmlFor="icon-button-file"> <label htmlFor="icon-button-file">
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} /> <input
<IconButton color="primary" aria-label="upload file" component="span"> hidden
id="icon-button-file"
type="file"
accept=".txt"
onChange={e => parseUrlListFile(e)}
/>
<IconButton
color="primary"
aria-label="upload file"
component="span"
>
<FileUpload /> <FileUpload />
</IconButton> </IconButton>
</label> </label>
@@ -250,21 +264,28 @@ export default function DownloadDialog({ open, onClose }: Props) {
variant="outlined" variant="outlined"
onChange={handleCustomArgsChange} onChange={handleCustomArgsChange}
value={customArgs} value={customArgs}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)} disabled={
!isConnected ||
(settings.formatSelection && downloadFormats != null)
}
/> />
</Grid> </Grid>
} }
{ {
settings.fileRenaming && settings.fileRenaming &&
<Grid item xs={8}> <Grid item xs={settings.pathOverriding ? 8 : 12}>
<TextField <TextField
sx={{ mt: 1 }}
ref={customFilenameInputRef} ref={customFilenameInputRef}
fullWidth fullWidth
label={i18n.t('customFilename')} label={i18n.t('customFilename')}
variant="outlined" variant="outlined"
value={fileNameOverride} value={filenameTemplate}
onChange={handleFilenameOverrideChange} onChange={handleFilenameTemplateChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)} disabled={
!isConnected ||
(settings.formatSelection && downloadFormats != null)
}
/> />
</Grid> </Grid>
} }
@@ -272,30 +293,69 @@ export default function DownloadDialog({ open, onClose }: Props) {
settings.pathOverriding && settings.pathOverriding &&
<Grid item xs={4}> <Grid item xs={4}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('customPath')}</InputLabel> <Autocomplete
<Select disablePortal
label={i18n.t('customPath')} options={availableDownloadPaths.map((dir) => ({ label: dir, dir }))}
defaultValue={0} autoHighlight
variant={'outlined'} getOptionLabel={(option) => option.label}
value={downloadPath} onChange={(_, value) => {
onChange={(e) => setDownloadPath(Number(e.target.value))} setDownloadPath(value?.dir!)
> }}
{availableDownloadPaths.map((val: string, idx: number) => ( renderOption={(props, option) => (
<MenuItem key={idx} value={idx}>{val}</MenuItem> <Box
))} component="li"
</Select> sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...props}>
{option.label}
</Box>
)}
sx={{ width: '100%', mt: 1 }}
renderInput={(params) => <TextField {...params} label={i18n.t('customPath')} />}
/>
</FormControl> </FormControl>
</Grid> </Grid>
} }
</Grid> </Grid>
<Grid container spacing={1} pt={2}> <Suspense>
{savedTemplates.length > 0 && <ExtraDownloadOptions />}
</Suspense>
<Grid container spacing={1} pt={2} justifyContent="space-between">
<Grid item>
<Grid item>
<FormControlLabel
control={<Checkbox onChange={() => setIsPlaylist(state => !state)} />}
checked={isPlaylist}
label={i18n.t('playlistCheckbox')}
/>
</Grid>
<Grid 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 item> <Grid item>
<Button <Button
variant="contained" variant="contained"
disabled={url === ''} disabled={url === ''}
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()} onClick={() => settings.formatSelection
? startTransition(() => sendUrlFormatSelection())
: sendUrl()
}
> >
{settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')} {
settings.formatSelection
? i18n.t('selectFormatButton')
: i18n.t('startButton')
}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>
@@ -329,7 +389,9 @@ export default function DownloadDialog({ open, onClose }: Props) {
pickedAudioFormat={pickedAudioFormat} pickedAudioFormat={pickedAudioFormat}
/>} />}
</Container> </Container>
</Box>
</Dialog> </Dialog>
</div>
) )
} }
export default DownloadDialog

View File

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

View File

@@ -1,34 +1,35 @@
import { Grid } from "@mui/material" import { Grid } from '@mui/material'
import { Fragment } from "react" import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import DownloadCard from './DownloadCard'
import type { RPCResult } from "../types" const DownloadsCardView: React.FC = () => {
import { StackableResult } from "./StackableResult" const downloads = useRecoilValue(activeDownloadsState)
type Props = { const { i18n } = useI18n()
downloads: RPCResult[] const { client } = useRPC()
abortFunction: (id: string) => void const { pushMessage } = useToast()
}
const abort = (id: string) => client.kill(id)
export function DownloadsCardView({ downloads, abortFunction }: Props) {
return ( return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}> <Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
{ {
downloads.map(download => ( downloads.map(download => (
<Grid item xs={4} sm={8} md={6} key={download.id}> <Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
<Fragment> <DownloadCard
<StackableResult download={download}
title={download.info.title} onStop={() => abort(download.id)}
thumbnail={download.info.thumbnail} onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
percentage={download.progress.percentage}
stopCallback={() => abortFunction(download.id)}
resolution={download.info.resolution ?? ''}
speed={download.progress.speed}
size={download.info.filesize_approx ?? 0}
/> />
</Fragment>
</Grid> </Grid>
)) ))
} }
</Grid> </Grid>
) )
} }
export default DownloadsCardView

View File

@@ -1,82 +0,0 @@
import {
Button,
Grid,
LinearProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from "@mui/material"
import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
import type { RPCResult } from "../types"
type Props = {
downloads: RPCResult[]
abortFunction: Function
}
export function DownloadsListView({ downloads, abortFunction }: Props) {
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
<Grid item xs={12}>
<TableContainer component={Paper} sx={{ minHeight: '80vh' }} elevation={2}>
<Table>
<TableHead>
<TableRow>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Title</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Progress</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Speed</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Size</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Actions</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{
downloads.map(download => (
<TableRow key={download.id}>
<TableCell>{ellipsis(download.info.title, 80)}</TableCell>
<TableCell>
<LinearProgress
value={
download.progress.percentage === '-1' ? 100 :
Number(download.progress.percentage.replace('%', ''))
}
variant="determinate"
color={download.progress.percentage === '-1' ? 'success' : 'primary'}
/>
</TableCell>
<TableCell>{formatSpeedMiB(download.progress.speed)}</TableCell>
<TableCell>{roundMiB(download.info.filesize_approx ?? 0)}</TableCell>
<TableCell>
<Button
variant="contained"
size="small"
onClick={() => abortFunction(download.id)}
>
{download.progress.percentage === '-1' ? 'Remove' : 'Stop'}
</Button>
</TableCell>
</TableRow>
))
}
</TableBody>
</Table>
</TableContainer>
</Grid>
</Grid>
)
}

View File

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

View File

@@ -0,0 +1,53 @@
import ErrorIcon from '@mui/icons-material/Error'
import { Button, Container, SvgIcon, Typography, styled } from '@mui/material'
import { Link } from 'react-router-dom'
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'
})
const ErrorBoundary: React.FC = () => {
return (
<FlexContainer>
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
<SvgIcon sx={{ fontSize: '200px' }}>
<ErrorIcon />
</SvgIcon>
</Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
An error occurred :\
</Title>
<Title fontWeight={'400'} fontSize={28} color={'gray'}>
Check your settings!
</Title>
<Link to={'/settings'} >
<Button variant='contained' sx={{ mt: 2 }}>
Goto Settings
</Button>
</Link>
<Typography sx={{ mt: 2 }} color={'gray'} fontWeight={500}>
Or login if authentication is enabled
</Typography>
<Link to={'/login'} >
<Button variant='contained' sx={{ mt: 2 }}>
login
</Button>
</Link>
</FlexContainer>
)
}
export default ErrorBoundary

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import StorageIcon from '@mui/icons-material/Storage'
import { useRecoilValue } from 'recoil'
import { freeSpaceBytesState } from '../atoms/status'
import { formatSize } from '../utils'
const FreeSpaceIndicator = () => {
const freeSpace = useRecoilValue(freeSpaceBytesState)
return (
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 3
}}>
<StorageIcon />
<span>
{formatSize(freeSpace)}
</span>
</div>
)
}
export default FreeSpaceIndicator

View File

@@ -0,0 +1,67 @@
import { Suspense, useState } from 'react'
import { useRecoilState } from 'recoil'
import { loadingAtom, optimisticDownloadsState } from '../atoms/ui'
import { useToast } from '../hooks/toast'
import DownloadDialog from './DownloadDialog'
import HomeSpeedDial from './HomeSpeedDial'
import TemplatesEditor from './TemplatesEditor'
const HomeActions: React.FC = () => {
const [, setIsLoading] = useRecoilState(loadingAtom)
const [optimistic, setOptimistic] = useRecoilState(optimisticDownloadsState)
const [openDownload, setOpenDownload] = useState(false)
const [openEditor, setOpenEditor] = useState(false)
const { pushMessage } = useToast()
// it's stupid because it will be overriden on the next server tick
const handleOptimisticUpdate = (url: string) => setOptimistic([
...optimistic, {
id: url,
info: {
created_at: new Date().toISOString(),
thumbnail: '',
title: url,
url: url
},
progress: {
eta: Number.MAX_SAFE_INTEGER,
percentage: '0%',
process_status: 0,
speed: 0
}
}
])
return (
<>
<HomeSpeedDial
onDownloadOpen={() => setOpenDownload(true)}
onEditorOpen={() => setOpenEditor(true)}
/>
<Suspense>
<DownloadDialog
open={openDownload}
onClose={() => {
setOpenDownload(false)
setIsLoading(true)
}}
// TODO: handle optimistic UI update
onDownloadStart={(url) => {
handleOptimisticUpdate(url)
pushMessage(`Requested ${url}`, 'info')
setOpenDownload(false)
setIsLoading(true)
}}
/>
</Suspense>
<TemplatesEditor
open={openEditor}
onClose={() => setOpenEditor(false)}
/>
</>
)
}
export default HomeActions

View File

@@ -0,0 +1,59 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'
import {
SpeedDial,
SpeedDialAction,
SpeedDialIcon
} from '@mui/material'
import { useRecoilState } from 'recoil'
import { listViewState } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
type Props = {
onDownloadOpen: () => void
onEditorOpen: () => void
}
const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const [listView, setListView] = useRecoilState(listViewState)
const { i18n } = useI18n()
const { client } = useRPC()
const abort = () => client.killAll()
return (
<SpeedDial
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
tooltipTitle={listView ? 'Card view' : 'Table view'}
onClick={() => setListView(state => !state)}
/>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={abort}
/>
<SpeedDialAction
icon={<BuildCircleIcon />}
tooltipTitle={i18n.t('templatesEditor')}
onClick={onEditorOpen}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={i18n.t('newDownloadButton')}
onClick={onDownloadOpen}
/>
</SpeedDial>
)
}
export default HomeSpeedDial

View File

@@ -0,0 +1,18 @@
import { Backdrop, CircularProgress } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
const LoadingBackdrop: React.FC = () => {
const isLoading = useRecoilValue(loadingAtom)
return (
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isLoading}
>
<CircularProgress color="primary" />
</Backdrop>
)
}
export default LoadingBackdrop

View File

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

View File

@@ -1,24 +1,27 @@
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import LogoutIcon from '@mui/icons-material/Logout' import LogoutIcon from '@mui/icons-material/Logout'
import { getHttpEndpoint } from '../utils' 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'
export default function Logout() { export default function Logout() {
const navigate = useNavigate() const navigate = useNavigate()
const url = useRecoilValue(serverURL)
const logout = async () => { const logout = async () => {
const res = await fetch(`${getHttpEndpoint()}/auth/logout`) localStorage.removeItem('token')
if (res.ok) {
navigate('/login') navigate('/login')
} }
}
const { i18n } = useI18n()
return ( return (
<ListItemButton onClick={logout}> <ListItemButton onClick={logout}>
<ListItemIcon> <ListItemIcon>
<LogoutIcon /> <LogoutIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="RPC authentication" /> <ListItemText primary={i18n.t('rpcAuthenticationLabel')} />
</ListItemButton> </ListItemButton>
) )
} }

View File

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

View File

@@ -1,5 +1,8 @@
import CloudDownloadIcon from '@mui/icons-material/CloudDownload' import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import { Container, SvgIcon, Typography, styled } from '@mui/material' import { Container, SvgIcon, Typography, styled } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useI18n } from '../hooks/useI18n'
const FlexContainer = styled(Container)({ const FlexContainer = styled(Container)({
display: 'flex', display: 'flex',
@@ -19,6 +22,13 @@ const Title = styled(Typography)({
}) })
export default function Splash() { export default function Splash() {
const { i18n } = useI18n()
const activeDownloads = useRecoilValue(activeDownloadsState)
if (activeDownloads.length !== 0) {
return null
}
return ( return (
<FlexContainer> <FlexContainer>
<Title fontWeight={'500'} fontSize={72} color={'gray'}> <Title fontWeight={'500'} fontSize={72} color={'gray'}>
@@ -27,7 +37,7 @@ export default function Splash() {
</SvgIcon> </SvgIcon>
</Title> </Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}> <Title fontWeight={'500'} fontSize={36} color={'gray'}>
No active downloads {i18n.t('splashText')}
</Title> </Title>
</FlexContainer> </FlexContainer>
) )

View File

@@ -1,107 +0,0 @@
import { EightK, FourK, Hd, Sd } from '@mui/icons-material'
import {
Button,
Card,
CardActionArea,
CardActions,
CardContent,
CardMedia,
Chip,
LinearProgress,
Skeleton,
Stack,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { ellipsis, formatSpeedMiB, roundMiB } from '../utils'
type Props = {
title: string,
thumbnail: string,
resolution: string
percentage: string,
size: number,
speed: number,
stopCallback: VoidFunction,
}
export function StackableResult({
title,
thumbnail,
resolution,
percentage,
speed,
size,
stopCallback
}: Props) {
const [isCompleted, setIsCompleted] = useState(false)
useEffect(() => {
if (percentage === '-1') {
setIsCompleted(true)
}
}, [percentage])
const percentageToNumber = () => isCompleted ? 100 : Number(percentage.replace('%', ''))
const guessResolution = (xByY: string): any => {
if (!xByY) return null;
if (xByY.includes('4320')) return (<EightK color="primary" />);
if (xByY.includes('2160')) return (<FourK color="primary" />);
if (xByY.includes('1080')) return (<Hd color="primary" />);
if (xByY.includes('720')) return (<Sd color="primary" />);
return null;
}
return (
<Card>
<CardActionArea>
{thumbnail !== '' ?
<CardMedia
component="img"
height={180}
image={thumbnail}
/> :
<Skeleton variant="rectangular" height={180} />
}
<CardContent>
{title !== '' ?
<Typography gutterBottom variant="h6" component="div">
{ellipsis(title, 54)}
</Typography> :
<Skeleton />
}
<Stack direction="row" spacing={1} py={2}>
<Chip
label={isCompleted ? 'Completed' : 'Downloading'}
color="primary"
size="small"
/>
<Typography>{!isCompleted ? percentage : ''}</Typography>
<Typography> {!isCompleted ? formatSpeedMiB(speed) : ''}</Typography>
<Typography>{roundMiB(size ?? 0)}</Typography>
{guessResolution(resolution)}
</Stack>
{percentage ?
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted ? "secondary" : "primary"}
/> :
null
}
</CardContent>
</CardActionArea>
<CardActions>
<Button
variant="contained"
size="small"
color="primary"
onClick={stopCallback}
>
{isCompleted ? "Clear" : "Stop"}
</Button>
</CardActions>
</Card>
)
}

View File

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

View File

@@ -1,27 +1,35 @@
import Brightness4 from '@mui/icons-material/Brightness4'
import Brightness5 from '@mui/icons-material/Brightness5'
import BrightnessAuto from '@mui/icons-material/BrightnessAuto'
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useDispatch, useSelector } from 'react-redux' import { useRecoilState } from 'recoil'
import { setTheme } from '../features/settings/settingsSlice' import { Theme, themeState } from '../atoms/settings'
import { RootState } from '../stores/store' import { useI18n } from '../hooks/useI18n'
import { Brightness4, Brightness5 } from '@mui/icons-material'
export default function ThemeToggler() { const ThemeToggler: React.FC = () => {
const settings = useSelector((state: RootState) => state.settings) const [theme, setTheme] = useRecoilState(themeState)
const dispatch = useDispatch()
const actions: Record<Theme, React.ReactNode> = {
system: <BrightnessAuto />,
light: <Brightness4 />,
dark: <Brightness5 />,
}
const themes: Theme[] = ['system', 'light', 'dark']
const currentTheme = themes.indexOf(theme)
const { i18n } = useI18n()
return ( return (
<ListItemButton onClick={() => { <ListItemButton onClick={() => {
settings.theme === 'light' setTheme(themes[(currentTheme + 1) % themes.length])
? dispatch(setTheme('dark'))
: dispatch(setTheme('light'))
}}> }}>
<ListItemIcon> <ListItemIcon>
{ {actions[theme]}
settings.theme === 'light'
? <Brightness4 />
: <Brightness5 />
}
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Toggle theme" /> <ListItemText primary={i18n.t('themeTogglerLabel')} />
</ListItemButton> </ListItemButton>
) )
} }
export default ThemeToggler

View File

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

View File

@@ -1,7 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface FormatSelectionState {
bestFormat: string
audioFormat: string
videoFormat: string
}

View File

@@ -1,99 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
export type LanguageUnion = "english" | "chinese" | "russian" | "italian" | "spanish" | "korean" | "japanese" | "catalan"
export type ThemeUnion = "light" | "dark"
export interface SettingsState {
serverAddr: string
serverPort: string
language: LanguageUnion
theme: ThemeUnion
cliArgs: string
formatSelection: boolean
ratelimit: string
fileRenaming: boolean
pathOverriding: boolean
enableCustomArgs: boolean
listView: boolean
}
const initialState: SettingsState = {
serverAddr: localStorage.getItem("server-addr") || window.location.hostname,
serverPort: localStorage.getItem("server-port") || window.location.port,
language: (localStorage.getItem("language") || "english") as LanguageUnion,
theme: (localStorage.getItem("theme") || "light") as ThemeUnion,
cliArgs: localStorage.getItem("cli-args") ?? "",
formatSelection: localStorage.getItem("format-selection") === "true",
ratelimit: localStorage.getItem("rate-limit") ?? "",
fileRenaming: localStorage.getItem("file-renaming") === "true",
pathOverriding: localStorage.getItem("path-overriding") === "true",
enableCustomArgs: localStorage.getItem("enable-custom-args") === "true",
listView: localStorage.getItem("listview") === "true",
}
export const settingsSlice = createSlice({
name: "settings",
initialState,
reducers: {
setServerAddr: (state, action: PayloadAction<string>) => {
state.serverAddr = action.payload
localStorage.setItem("server-addr", action.payload)
},
setServerPort: (state, action: PayloadAction<string>) => {
state.serverPort = action.payload
localStorage.setItem("server-port", action.payload)
},
setLanguage: (state, action: PayloadAction<LanguageUnion>) => {
state.language = action.payload
localStorage.setItem("language", action.payload)
},
setCliArgs: (state, action: PayloadAction<string>) => {
state.cliArgs = action.payload
localStorage.setItem("cli-args", action.payload)
},
setTheme: (state, action: PayloadAction<ThemeUnion>) => {
state.theme = action.payload
localStorage.setItem("theme", action.payload)
},
setFormatSelection: (state, action: PayloadAction<boolean>) => {
state.formatSelection = action.payload
localStorage.setItem("format-selection", action.payload.toString())
},
setRateLimit: (state, action: PayloadAction<string>) => {
state.ratelimit = action.payload
localStorage.setItem("rate-limit", action.payload)
},
setPathOverriding: (state, action: PayloadAction<boolean>) => {
state.pathOverriding = action.payload
localStorage.setItem("path-overriding", action.payload.toString())
},
setFileRenaming: (state, action: PayloadAction<boolean>) => {
state.fileRenaming = action.payload
localStorage.setItem("file-renaming", action.payload.toString())
},
setEnableCustomArgs: (state, action: PayloadAction<boolean>) => {
state.enableCustomArgs = action.payload
localStorage.setItem("enable-custom-args", action.payload.toString())
},
toggleListView: (state) => {
state.listView = !state.listView
localStorage.setItem("listview", state.listView.toString())
},
}
})
export const {
setLanguage,
setCliArgs,
setTheme,
setServerAddr,
setServerPort,
setFormatSelection,
setRateLimit,
setFileRenaming,
setPathOverriding,
setEnableCustomArgs,
toggleListView
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@@ -1,55 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
export interface StatusState {
connected: boolean,
updated: boolean,
downloading: boolean,
freeSpace: number,
}
const initialState: StatusState = {
connected: false,
updated: false,
downloading: false,
freeSpace: 0,
}
export const statusSlice = createSlice({
name: 'status',
initialState,
reducers: {
connected: (state) => {
state.connected = true
},
disconnected: (state) => {
state.connected = false
},
updated: (state) => {
state.updated = true
},
alreadyUpdated: (state) => {
state.updated = false
},
downloading: (state) => {
state.downloading = true
},
finished: (state) => {
state.downloading = false
},
setFreeSpace: (state, action: PayloadAction<number>) => {
state.freeSpace = action.payload
}
}
})
export const {
connected,
disconnected,
updated,
alreadyUpdated,
downloading,
finished,
setFreeSpace
} = statusSlice.actions
export default statusSlice.reducer

View File

@@ -0,0 +1,19 @@
import { AlertColor } from '@mui/material'
import { useRecoilState } from 'recoil'
import { toastListState } from '../atoms/toast'
export const useToast = () => {
const [, setToasts] = useRecoilState(toastListState)
return {
pushMessage: (message: string, severity?: AlertColor) => {
setToasts(state => [...state, {
open: true,
message: message,
severity: severity,
autoClose: true,
createdAt: Date.now()
}])
}
}
}

View File

@@ -0,0 +1,10 @@
import { useRecoilValue } from 'recoil'
import { i18nBuilderState } from '../atoms/i18n'
export const useI18n = () => {
const instance = useRecoilValue(i18nBuilderState)
return {
i18n: instance
}
}

View File

@@ -0,0 +1,10 @@
import { useRecoilValue } from 'recoil'
import { rpcClientState } from '../atoms/rpc'
export const useRPC = () => {
const client = useRecoilValue(rpcClientState)
return {
client
}
}

View File

@@ -2,6 +2,14 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { App } from './App' import { App } from './App'
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css'
import '@fontsource/roboto/700.css'
import '@fontsource/roboto-mono'
const root = createRoot(document.getElementById('root')!) const root = createRoot(document.getElementById('root')!)
root.render( root.render(

View File

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

View File

@@ -1,10 +0,0 @@
export function on(eventType: string, listener: any) {
document.addEventListener(eventType, listener)
}
export const serverStates = {
PROC_DOWNLOAD: 'download',
PROC_MERGING: 'merging',
PROC_ABORT: 'abort',
PROG_DONE: 'status_done',
}

View File

@@ -1,21 +1,30 @@
import * as E from 'fp-ts/Either' import { tryCatch } from 'fp-ts/TaskEither'
import { pipe } from 'fp-ts/function'
type FetchInit = { export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
url: string, () => fetcher<T>(url, opt),
opt?: RequestInit (e) => `error while fetching: ${e}`
)
const fetcher = async <T>(url: string, opt?: RequestInit) => {
const jwt = localStorage.getItem('token')
if (opt && !opt.headers) {
opt.headers = {
'Content-Type': 'application/json',
}
} }
export async function ffetch<T>( const res = await fetch(url, {
url: string, ...opt,
onSuccess: (res: T) => void, headers: {
onError: (err: string) => void, ...opt?.headers,
opt?: RequestInit, 'X-Authentication': jwt ?? ''
) { }
const res = await fetch(url, opt) })
if (!res.ok) { if (!res.ok) {
onError(await res.text()) throw await res.text()
return
} }
onSuccess(await res.json() as T) return res.json() as T
} }

View File

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

View File

@@ -1,15 +1,33 @@
import type { DLMetadata, RPCRequest, RPCResponse } from '../types' import { Observable } from 'rxjs'
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
import { webSocket } from 'rxjs/webSocket' import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../utils'
export const socket$ = webSocket<any>(getWebSocketEndpoint()) type DownloadRequestArgs = {
url: string,
args: string,
pathOverride?: string,
renameTo?: string,
playlist?: boolean
}
export class RPCClient { export class RPCClient {
private seq: number private seq: number
private httpEndpoint: string
private readonly _socket$: WebSocketSubject<any>
private readonly token?: string
constructor() { constructor(httpEndpoint: string, webSocketEndpoint: string, token?: string) {
this.seq = 0 this.seq = 0
this.httpEndpoint = httpEndpoint
this._socket$ = webSocket<any>({
url: token ? `${webSocketEndpoint}?token=${token}` : webSocketEndpoint
})
this.token = token
}
public get socket$(): Observable<RPCResponse<RPCResult[]>> {
return this._socket$
} }
private incrementSeq() { private incrementSeq() {
@@ -17,15 +35,25 @@ export class RPCClient {
} }
private send(req: RPCRequest) { private send(req: RPCRequest) {
socket$.next({ this._socket$.next({
...req, ...req,
id: this.incrementSeq(), id: this.incrementSeq(),
}) })
} }
private argsSanitizer(args: string) {
return args
.split(' ')
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
.filter(Boolean)
}
private async sendHTTP<T>(req: RPCRequest) { private async sendHTTP<T>(req: RPCRequest) {
const res = await fetch(getHttpRPCEndpoint(), { const res = await fetch(this.httpEndpoint, {
method: 'POST', method: 'POST',
headers: {
'X-Authentication': this.token ?? ''
},
body: JSON.stringify({ body: JSON.stringify({
...req, ...req,
id: this.incrementSeq(), id: this.incrementSeq(),
@@ -36,18 +64,47 @@ export class RPCClient {
return data return data
} }
public download(url: string, args: string, pathOverride = '', renameTo = '') { public download(req: DownloadRequestArgs) {
if (url) { if (!req.url) {
this.send({ return
method: 'Service.Exec', }
const rename = req.args.includes('-o')
? req.args
.substring(req.args.indexOf('-o'))
.replaceAll("'", '')
.replaceAll('"', '')
.split('-o')
.map(s => s.trim())
.join('')
.split(' ')
.at(0) ?? ''
: ''
const sanitizedArgs = this.argsSanitizer(
req.args.replace('-o', '').replace(rename, '')
)
if (req.playlist) {
return this.sendHTTP({
method: 'Service.ExecPlaylist',
params: [{ params: [{
URL: url.split("?list").at(0)!, URL: req.url,
Params: args.split(" ").map(a => a.trim()), Params: sanitizedArgs,
Path: pathOverride, Path: req.pathOverride,
Rename: renameTo, Rename: req.renameTo || rename,
}] }]
}) })
} }
this.sendHTTP({
method: 'Service.Exec',
params: [{
URL: req.url.split('?list').at(0)!,
Params: sanitizedArgs,
Path: req.pathOverride,
Rename: req.renameTo || rename,
}]
})
} }
public formats(url: string) { public formats(url: string) {
@@ -55,7 +112,7 @@ export class RPCClient {
return this.sendHTTP<DLMetadata>({ return this.sendHTTP<DLMetadata>({
method: 'Service.Formats', method: 'Service.Formats',
params: [{ params: [{
URL: url.split("?list").at(0)!, URL: url.split('?list').at(0)!,
}] }]
}) })
} }
@@ -69,14 +126,14 @@ export class RPCClient {
} }
public kill(id: string) { public kill(id: string) {
this.send({ this.sendHTTP({
method: 'Service.Kill', method: 'Service.Kill',
params: [id], params: [id],
}) })
} }
public killAll() { public killAll() {
this.send({ this.sendHTTP({
method: 'Service.KillAll', method: 'Service.KillAll',
params: [], params: [],
}) })

View File

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

View File

@@ -1,14 +1,17 @@
import { CircularProgress } from '@mui/material' import { CircularProgress } from '@mui/material'
import { Suspense, lazy } from 'react' import { Suspense, lazy } from 'react'
import { createBrowserRouter } from 'react-router-dom' import { createHashRouter } from 'react-router-dom'
import Layout from './Layout' import Layout from './Layout'
import Terminal from './views/Terminal'
const Home = lazy(() => import('./views/Home')) 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'))
export const router = createBrowserRouter([ const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
export const router = createHashRouter([
{ {
path: '/', path: '/',
Component: () => <Layout />, Component: () => <Layout />,
@@ -19,6 +22,11 @@ export const router = createBrowserRouter([
<Suspense fallback={<CircularProgress />}> <Suspense fallback={<CircularProgress />}>
<Home /> <Home />
</Suspense > </Suspense >
),
errorElement: (
<Suspense fallback={<CircularProgress />}>
<ErrorBoundary />
</Suspense >
) )
}, },
{ {
@@ -29,12 +37,25 @@ export const router = createBrowserRouter([
</Suspense > </Suspense >
) )
}, },
{
path: '/log',
element: (
<Suspense fallback={<CircularProgress />}>
<Terminal />
</Suspense >
)
},
{ {
path: '/archive', path: '/archive',
element: ( element: (
<Suspense fallback={<CircularProgress />}> <Suspense fallback={<CircularProgress />}>
<Archive /> <Archive />
</Suspense > </Suspense >
),
errorElement: (
<Suspense fallback={<CircularProgress />}>
<ErrorBoundary />
</Suspense >
) )
}, },
{ {
@@ -45,6 +66,14 @@ export const router = createBrowserRouter([
</Suspense > </Suspense >
) )
}, },
{
path: '/error',
element: (
<Suspense fallback={<CircularProgress />}>
<ErrorBoundary />
</Suspense >
)
},
] ]
}, },
]) ])

View File

@@ -1,15 +0,0 @@
import { configureStore } from '@reduxjs/toolkit'
import settingsReducer from '../features/settings/settingsSlice'
import statussReducer from '../features/status/statusSlice'
export const store = configureStore({
reducer: {
settings: settingsReducer,
status: statussReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

View File

@@ -6,6 +6,7 @@ export type RPCMethods =
| "Service.KillAll" | "Service.KillAll"
| "Service.FreeSpace" | "Service.FreeSpace"
| "Service.Formats" | "Service.Formats"
| "Service.ExecPlaylist"
| "Service.DirectoryTree" | "Service.DirectoryTree"
| "Service.UpdateExecutable" | "Service.UpdateExecutable"
@@ -15,11 +16,11 @@ export type RPCRequest = {
id?: string id?: string
} }
export type RPCResponse<T> = { export type RPCResponse<T> = Readonly<{
result: T result: T
error: number | null error: number | null
id?: string id?: string
} }>
type DownloadInfo = { type DownloadInfo = {
url: string url: string
@@ -37,20 +38,21 @@ type DownloadProgress = {
speed: number speed: number
eta: number eta: number
percentage: string percentage: string
process_status: number
} }
export type RPCResult = { export type RPCResult = Readonly<{
id: string id: string
progress: DownloadProgress progress: DownloadProgress
info: DownloadInfo info: DownloadInfo
} }>
export type RPCParams = { export type RPCParams = {
URL: string URL: string
Params?: string Params?: string
} }
export interface DLMetadata { export type DLMetadata = {
formats: Array<DLFormat> formats: Array<DLFormat>
best: DLFormat best: DLFormat
thumbnail: string thumbnail: string
@@ -81,3 +83,8 @@ export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
export type PlayRequest = Pick<DirectoryEntry, 'path'> export type PlayRequest = Pick<DirectoryEntry, 'path'>
export type CustomTemplate = {
id: string
name: string
content: string
}

View File

@@ -1,3 +1,6 @@
import { pipe } from 'fp-ts/lib/function'
import type { RPCResponse } from "./types"
/** /**
* Validate an ip v4 via regex * Validate an ip v4 via regex
* @param {string} ipAddr * @param {string} ipAddr
@@ -8,31 +11,15 @@ export function validateIP(ipAddr: string): boolean {
return ipRegex.test(ipAddr) return ipRegex.test(ipAddr)
} }
/** export function validateDomain(url: string): boolean {
* Validate a domain via regex. const urlRegex = /(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
* The validation pass if the domain respects the following formats: const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
* - localhost
* - domain.tld const [name, slug] = url.split('/')
* - dir.domain.tld
* @param domainName return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug)
* @returns domain validity test
*/
export function validateDomain(domainName: string): boolean {
let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/
return domainRegex.test(domainName) || domainName === 'localhost'
} }
/**
* Validate a domain via regex.
* Exapmples
* - http://example.com
* - https://example.com
* - http://www.example.com
* - https://www.example.com
* - http://10.0.0.1/[something]/[something-else]
* @param url
* @returns url validity test
*/
export function isValidURL(url: string): boolean { export function isValidURL(url: string): boolean {
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/ let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
return urlRegex.test(url) return urlRegex.test(url)
@@ -45,52 +32,60 @@ export function ellipsis(str: string, lim: number): string {
return '' return ''
} }
/**
* Parse the downlaod speed sent by server and converts it to KiB/s
* @param str the downlaod speed, ex. format: 5 MiB/s => 5000 | 50 KiB/s => 50
* @returns download speed in KiB/s
*/
export function detectSpeed(str: string): number {
let effective = str.match(/[\d,]+(\.\d+)?/)![0]
const unit = str.replace(effective, '')
switch (unit) {
case 'MiB/s':
return Number(effective) * 1000
case 'KiB/s':
return Number(effective)
default:
return 0
}
}
export function toFormatArgs(codes: string[]): string { export function toFormatArgs(codes: string[]): string {
if (codes.length > 1) { if (codes.length > 1) {
return codes.reduce((v, a) => ` -f ${v}+${a}`) return codes.reduce((v, a) => ` -f ${v}+${a}`)
} }
if (codes.length === 1) { if (codes.length === 1) {
return ` -f ${codes[0]}`; return ` -f ${codes[0]}`
} }
return ''; return ''
} }
export function getWebSocketEndpoint() { export function formatSize(bytes: number): string {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' const threshold = 1024
return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/ws` const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
let i = 0
while (bytes >= threshold) {
bytes /= threshold
i = i + 1
} }
export function getHttpRPCEndpoint() { return `${bytes.toFixed(i == 0 ? 0 : 2)} ${units.at(i)}`
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/http`
} }
export function getHttpEndpoint() { export const formatSpeedMiB = (val: number) =>
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}` `${(val / 1_048_576).toFixed(2)} MiB/s`
export const datetimeCompareFunc = (a: string, b: string) =>
new Date(a).getTime() - new Date(b).getTime()
export function isRPCResponse(object: any): object is RPCResponse<any> {
return 'result' in object && 'id' in object
} }
export function formatGiB(bytes: number) { export function mapProcessStatus(status: number) {
return `${(bytes / 1_000_000_000).toFixed(0)}GiB` switch (status) {
case 0:
return 'Pending'
case 1:
return 'Downloading'
case 2:
return 'Completed'
case 3:
return 'Error'
default:
return 'Pending'
}
} }
export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB` export const prefersDarkMode = () =>
export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s` window.matchMedia('(prefers-color-scheme: dark)').matches
export const dateTimeComparatorFunc = (a: string, b: string) => new Date(a).getTime() - new Date(b).getTime() export const base64URLEncode = (s: string) => pipe(
s,
s => String.fromCodePoint(...new TextEncoder().encode(s)),
btoa,
encodeURIComponent
)

View File

@@ -14,6 +14,8 @@ import {
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
MenuItem,
MenuList,
Paper, Paper,
SpeedDial, SpeedDial,
SpeedDialAction, SpeedDialAction,
@@ -26,45 +28,57 @@ import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile' import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import VideoFileIcon from '@mui/icons-material/VideoFile' import VideoFileIcon from '@mui/icons-material/VideoFile'
import { Buffer } from 'buffer' import DownloadIcon from '@mui/icons-material/Download'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
import { useEffect, useMemo, useState, useTransition } from 'react' import { useEffect, useMemo, useState, useTransition } from 'react'
import { useSelector } from 'react-redux'
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
import { useObservable } from '../hooks/observable'
import { RootState } from '../stores/store'
import { DeleteRequest, DirectoryEntry } from '../types'
import { roundMiB } from '../utils'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
import { serverURL } from '../atoms/settings'
import { useObservable } from '../hooks/observable'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { DirectoryEntry } from '../types'
import { base64URLEncode, formatSize } from '../utils'
export default function Downloaded() { export default function Downloaded() {
const settings = useSelector((state: RootState) => state.settings) const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
const [showMenu, setShowMenu] = useState(false)
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
const serverAddr = useRecoilValue(serverURL)
const navigate = useNavigate() const navigate = useNavigate()
const [openDialog, setOpenDialog] = useState(false) const { i18n } = useI18n()
const { pushMessage } = useToast()
const serverAddr = const [openDialog, setOpenDialog] = useState(false)
`${window.location.protocol}//${settings.serverAddr}:${settings.serverPort}`
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), []) const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), []) const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const fetcher = () => ffetch<DirectoryEntry[]>( const fetcher = () => pipe(
ffetch<DirectoryEntry[]>(
`${serverAddr}/archive/downloaded`, `${serverAddr}/archive/downloaded`,
(d) => files$.next(d),
() => navigate('/login'),
{ {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
subdir: '', subdir: '',
}) })
} }
),
matchW(
(e) => {
pushMessage(e, 'error')
navigate('/login')
},
(d) => files$.next(d ?? []),
) )
)()
const fetcherSubfolder = (sub: string) => { const fetcherSubfolder = (sub: string) => {
const folders = sub.startsWith('/') const folders = sub.startsWith('/')
@@ -80,24 +94,32 @@ export default function Downloaded() {
? ['.', ..._upperLevel].join('/') ? ['.', ..._upperLevel].join('/')
: _upperLevel.join('/') : _upperLevel.join('/')
fetch(`${serverAddr}/archive/downloaded`, { const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ subdir: relpath }) body: JSON.stringify({ subdir: relpath })
}) })
.then(res => res.json())
.then(data => { pipe(
files$.next(sub task,
matchW(
(l) => pushMessage(l, 'error'),
(r) => files$.next(sub
? [{ ? [{
name: '..',
isDirectory: true, isDirectory: true,
isVideo: false,
modTime: '',
name: '..',
path: upperLevel, path: upperLevel,
}, ...data] shaSum: '',
: data size: 0,
}, ...r.filter(f => f.name !== '')]
: r.filter(f => f.name !== '')
) )
}) )
)()
} }
const selectable$ = useMemo(() => files$.pipe( const selectable$ = useMemo(() => files$.pipe(
@@ -117,29 +139,41 @@ export default function Downloaded() {
: selected$.next([...selected$.value, name]) : selected$.next([...selected$.value, name])
} }
const deleteSelected = () => { const deleteFile = (entry: DirectoryEntry) => pipe(
Promise.all(selectable ffetch(`${serverAddr}/archive/delete`, {
.filter(entry => entry.selected)
.map(entry => fetch(`${serverAddr}/archive/delete`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
path: entry.path, path: entry.path,
shaSum: entry.shaSum, shaSum: entry.shaSum,
} as DeleteRequest) })
})) }),
matchW(
(l) => pushMessage(l, 'error'),
(_) => fetcher()
)
)()
const deleteSelected = () => {
Promise.all(selectable
.filter(entry => entry.selected)
.map(deleteFile)
).then(fetcher) ).then(fetcher)
} }
useEffect(() => { useEffect(() => {
fetcher() fetcher()
}, [settings.serverAddr, settings.serverPort]) }, [serverAddr])
const onFileClick = (path: string) => startTransition(() => { const onFileClick = (path: string) => startTransition(() => {
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`) const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
})
const downloadFile = (path: string) => startTransition(() => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
}) })
const onFolderClick = (path: string) => startTransition(() => { const onFolderClick = (path: string) => startTransition(() => {
@@ -147,25 +181,55 @@ export default function Downloaded() {
}) })
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container
maxWidth="xl"
sx={{ mt: 4, mb: 4, height: '100%' }}
onClick={() => setShowMenu(false)}
>
<IconMenu
posX={menuPos.x}
posY={menuPos.y}
hide={!showMenu}
onDownload={() => {
if (currentFile) {
downloadFile(currentFile?.path)
setCurrentFile(undefined)
}
}}
onDelete={() => {
if (currentFile) {
deleteFile(currentFile)
setCurrentFile(undefined)
}
}}
/>
<Backdrop <Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }} sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!(files$.observed) || isPending} open={!(files$.observed) || isPending}
> >
<CircularProgress color="primary" /> <CircularProgress color="primary" />
</Backdrop> </Backdrop>
<Paper sx={{ <Paper
sx={{
p: 2, p: 2,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}}> }}
onClick={() => setShowMenu(false)}
>
<Typography py={1} variant="h5" color="primary"> <Typography py={1} variant="h5" color="primary">
{'Archive'} {i18n.t('archiveTitle')}
</Typography> </Typography>
<List sx={{ width: '100%', bgcolor: 'background.paper' }}> <List sx={{ width: '100%', bgcolor: 'background.paper' }}>
{selectable.length === 0 && 'No files found'} {selectable.length === 0 && 'No files found'}
{selectable.map((file, idx) => ( {selectable.map((file, idx) => (
<ListItem <ListItem
onContextMenu={(e) => {
e.preventDefault()
setCurrentFile(file)
setMenuPos({ x: e.clientX, y: e.clientY })
setShowMenu(true)
}}
key={idx} key={idx}
secondaryAction={ secondaryAction={
<div> <div>
@@ -173,14 +237,16 @@ export default function Downloaded() {
variant="caption" variant="caption"
component="span" component="span"
> >
{roundMiB(file.size)} {formatSize(file.size)}
</Typography> </Typography>
} }
{!file.isDirectory && <Checkbox {!file.isDirectory && <>
<Checkbox
edge="end" edge="end"
checked={file.selected} checked={file.selected}
onChange={() => addSelected(file.name)} onChange={() => addSelected(file.name)}
/>} />
</>}
</div> </div>
} }
disablePadding disablePadding
@@ -208,8 +274,8 @@ export default function Downloaded() {
</List> </List>
</Paper> </Paper>
<SpeedDial <SpeedDial
ariaLabel="SpeedDial basic example" ariaLabel='archive actions'
sx={{ position: 'absolute', bottom: 32, right: 32 }} sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />} icon={<SpeedDialIcon />}
> >
<SpeedDialAction <SpeedDialAction
@@ -241,11 +307,15 @@ export default function Downloaded() {
</ul> </ul>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button> <Button onClick={() => setOpenDialog(false)}>
<Button onClick={() => { Cancel
</Button>
<Button
onClick={() => {
deleteSelected() deleteSelected()
setOpenDialog(false) setOpenDialog(false)
}} autoFocus }}
autoFocus
> >
Ok Ok
</Button> </Button>
@@ -254,3 +324,42 @@ export default function Downloaded() {
</Container> </Container>
) )
} }
const IconMenu: React.FC<{
posX: number
posY: number
hide: boolean
onDownload: () => void
onDelete: () => void
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
return (
<Paper sx={{
width: 320,
maxWidth: '100%',
position: 'absolute',
top: posY,
left: posX,
display: hide ? 'none' : 'block',
zIndex: (theme) => theme.zIndex.drawer + 1,
}}>
<MenuList>
<MenuItem onClick={onDownload}>
<ListItemIcon>
<DownloadIcon fontSize="small" />
</ListItemIcon>
<ListItemText>
Download
</ListItemText>
</MenuItem>
<MenuItem onClick={onDelete}>
<ListItemIcon>
<DeleteForeverIcon fontSize="small" />
</ListItemIcon>
<ListItemText>
Delete
</ListItemText>
</MenuItem>
</MenuList>
</Paper>
)
}

View File

@@ -1,189 +1,18 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import { import {
Alert, Container
Backdrop,
CircularProgress,
Container,
Snackbar,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
styled
} from '@mui/material' } from '@mui/material'
import { useEffect, useMemo, useState } from 'react' import Downloads from '../components/Downloads'
import { useDispatch, useSelector } from 'react-redux' import HomeActions from '../components/HomeActions'
import DownloadDialog from '../components/DownloadDialog' import LoadingBackdrop from '../components/LoadingBackdrop'
import { DownloadsCardView } from '../components/DownloadsCardView'
import { DownloadsListView } from '../components/DownloadsListView'
import Splash from '../components/Splash' import Splash from '../components/Splash'
import { toggleListView } from '../features/settings/settingsSlice'
import { connected, setFreeSpace } from '../features/status/statusSlice'
import I18nBuilder from '../lib/intl'
import { RPCClient, socket$ } from '../lib/rpcClient'
import { RootState } from '../stores/store'
import type { RPCResponse, RPCResult } from '../types'
import { dateTimeComparatorFunc } from '../utils'
export default function Home() { export default function Home() {
// redux state
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()
// ephemeral state
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
const [showBackdrop, setShowBackdrop] = useState(true)
const [showToast, setShowToast] = useState(true)
const [openDialog, setOpenDialog] = useState(false)
const [socketHasError, setSocketHasError] = useState(false)
// memos
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
/* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/
useEffect(() => {
if (status.connected) { return }
const sub = socket$.subscribe({
next: () => {
dispatch(connected())
},
error: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
complete: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
})
return () => sub.unsubscribe()
}, [socket$, status.connected])
useEffect(() => {
if (status.connected) {
client.running()
const interval = setInterval(() => client.running(), 1000)
return () => clearInterval(interval)
}
}, [status.connected])
useEffect(() => {
client.freeSpace().then(bytes => dispatch(setFreeSpace(bytes.result)))
}, [])
useEffect(() => {
if (!status.connected) { return }
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
switch (typeof event.result) {
case 'object':
setActiveDownloads(
(event.result ?? [])
.filter((r) => !!r.info.url)
.sort((a, b) => dateTimeComparatorFunc(
b.info.created_at,
a.info.created_at,
))
)
break
default:
break
}
})
return () => sub.unsubscribe()
}, [socket$, status.connected])
useEffect(() => {
if (activeDownloads && activeDownloads.length >= 0) {
setShowBackdrop(false)
}
}, [activeDownloads?.length])
/**
* Abort a specific download if id's provided, other wise abort all running ones.
* @param id The download id / pid
* @returns void
*/
const abort = (id?: string) => {
if (id) {
client.kill(id)
return
}
client.killAll()
}
/* -------------------- styled components -------------------- */
const Input = styled('input')({
display: 'none',
})
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}>
<Backdrop <LoadingBackdrop />
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={showBackdrop}
>
<CircularProgress color="primary" />
</Backdrop>
{activeDownloads?.length === 0 &&
<Splash /> <Splash />
} <Downloads />
{ <HomeActions />
settings.listView ?
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
<DownloadsCardView downloads={activeDownloads ?? []} abortFunction={abort} />
}
<Snackbar
open={showToast === status.connected}
autoHideDuration={1500}
onClose={() => setShowToast(false)}
>
<Alert variant="filled" severity="success">
{`Connected to (${settings.serverAddr}:${settings.serverPort})`}
</Alert>
</Snackbar>
<Snackbar open={socketHasError}>
<Alert variant="filled" severity="error">
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
</Alert>
</Snackbar>
<SpeedDial
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<FormatListBulleted />}
tooltipTitle={`Table view`}
onClick={() => dispatch(toggleListView())}
/>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={() => abort()}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={`New download`}
onClick={() => setOpenDialog(true)}
/>
</SpeedDial>
<DownloadDialog open={openDialog} onClose={() => {
setOpenDialog(false)
activeDownloads?.length === 0
? setShowBackdrop(false)
: setShowBackdrop(true)
}} />
</Container> </Container>
) )
} }

View File

@@ -11,14 +11,19 @@ import {
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { getHttpEndpoint } from '../utils'
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 { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
const LoginContainer = styled(Container)({ const LoginContainer = styled(Container)({
display: 'flex', display: 'flex',
minWidth: '100%', minWidth: '100%',
minHeight: '100vh', minHeight: '85vh',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}) })
@@ -32,20 +37,48 @@ const Title = styled(Typography)({
}) })
export default function Login() { export default function Login() {
const [secret, setSecret] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [formHasError, setFormHasError] = useState(false) const [formHasError, setFormHasError] = useState(false)
const url = useRecoilValue(serverURL)
const navigate = useNavigate() const navigate = useNavigate()
const { pushMessage } = useToast()
const navigateAndReload = () => {
navigate('/')
window.location.reload()
}
const login = async () => { const login = async () => {
const res = await fetch(`${getHttpEndpoint()}/auth/login`, { const task = ffetch<string>(`${url}/auth/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ secret }) body: JSON.stringify({
username,
password,
}),
}) })
res.ok ? navigate('/') : setFormHasError(true)
pipe(
task,
matchW(
(error) => {
setFormHasError(true)
pushMessage(error, 'error')
},
(token) => {
console.log(token)
localStorage.setItem('token', token)
navigateAndReload()
}
)
)()
} }
return ( return (
@@ -59,17 +92,23 @@ export default function Login() {
Authentication token will expire after 30 days. Authentication token will expire after 30 days.
</Title> </Title>
<Title fontWeight={'500'} fontSize={16} color={'gray'}> <Title fontWeight={'500'} fontSize={16} color={'gray'}>
In order to enable RPC authentication append the --auth In order to enable RPC authentication append the --auth,
<br /> <br />
and --secret [secret] flags. --user [username] and --pass [password] flags.
</Title> </Title>
<TextField <TextField
id="outlined-password-input" label="Username"
label="RPC secret" type="text"
type="password" autoComplete="yt-dlp-webui-username"
autoComplete="current-password"
error={formHasError} error={formHasError}
onChange={e => setSecret(e.currentTarget.value)} onChange={e => setUsername(e.currentTarget.value)}
/>
<TextField
label="Password"
type="password"
autoComplete="yt-dlp-webui-password"
error={formHasError}
onChange={e => setPassword(e.currentTarget.value)}
/> />
<Button variant="contained" size="large" onClick={() => login()}> <Button variant="contained" size="large" onClick={() => login()}>
Submit Submit

View File

@@ -1,5 +1,6 @@
import { import {
Button, Button,
Checkbox,
Container, Container,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
@@ -11,14 +12,14 @@ import {
Paper, Paper,
Select, Select,
SelectChangeEvent, SelectChangeEvent,
Snackbar,
Stack, Stack,
Switch, Switch,
TextField, TextField,
Typography Typography,
capitalize
} from '@mui/material' } from '@mui/material'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useRecoilState } from 'recoil'
import { import {
Subject, Subject,
debounceTime, debounceTime,
@@ -26,38 +27,51 @@ import {
map, map,
takeWhile takeWhile
} from 'rxjs' } from 'rxjs'
import { CliArguments } from '../lib/argsParser'
import I18nBuilder from '../lib/intl'
import { RPCClient } from '../lib/rpcClient'
import { import {
LanguageUnion, Language,
ThemeUnion, Theme,
setCliArgs, appTitleState,
setEnableCustomArgs, enableCustomArgsState,
setFileRenaming, fileRenamingState,
setFormatSelection, formatSelectionState,
setLanguage, languageState,
setPathOverriding, languages,
setServerAddr, latestCliArgumentsState,
setServerPort, pathOverridingState,
setTheme servedFromReverseProxyState,
} from '../features/settings/settingsSlice' serverAddressState,
import { updated } from '../features/status/statusSlice' serverPortState,
import { RootState } from '../stores/store' themeState
} from '../atoms/settings'
import CookiesTextField from '../components/CookiesTextField'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser'
import { validateDomain, validateIP } from '../utils' import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() { export default function Settings() {
const dispatch = useDispatch() const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState)
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
const [serverPort, setServerPort] = useRecoilState(serverPortState)
const [language, setLanguage] = useRecoilState(languageState)
const [appTitle, setApptitle] = useRecoilState(appTitleState)
const [cliArgs, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [theme, setTheme] = useRecoilState(themeState)
const status = useSelector((state: RootState) => state.status) const [invalidIP, setInvalidIP] = useState(false)
const settings = useSelector((state: RootState) => state.settings)
const [invalidIP, setInvalidIP] = useState(false); const { i18n } = useI18n()
const { client } = useRPC()
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language]) const { pushMessage } = useToast()
const client = useMemo(() => new RPCClient(), []) const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), [])
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), []) const serverPort$ = useMemo(() => new Subject<string>(), [])
@@ -71,10 +85,12 @@ export default function Settings() {
.subscribe(addr => { .subscribe(addr => {
if (validateIP(addr)) { if (validateIP(addr)) {
setInvalidIP(false) setInvalidIP(false)
dispatch(setServerAddr(addr)) setServerAddr(addr)
pushMessage(i18n.t('restartAppMessage'), 'info')
} else if (validateDomain(addr)) { } else if (validateDomain(addr)) {
setInvalidIP(false) setInvalidIP(false)
dispatch(setServerAddr(addr)) setServerAddr(addr)
pushMessage(i18n.t('restartAppMessage'), 'info')
} else { } else {
setInvalidIP(true) setInvalidIP(true)
} }
@@ -90,7 +106,8 @@ export default function Settings() {
takeWhile(val => isFinite(val) && val <= 65535), takeWhile(val => isFinite(val) && val <= 65535),
) )
.subscribe(port => { .subscribe(port => {
dispatch(setServerPort(port.toString())) setServerPort(port)
pushMessage(i18n.t('restartAppMessage'), 'info')
}) })
return () => sub.unsubscribe() return () => sub.unsubscribe()
}, []) }, [])
@@ -98,31 +115,31 @@ export default function Settings() {
/** /**
* Language toggler handler * Language toggler handler
*/ */
const handleLanguageChange = (event: SelectChangeEvent<LanguageUnion>) => { const handleLanguageChange = (event: SelectChangeEvent<Language>) => {
dispatch(setLanguage(event.target.value as LanguageUnion)); setLanguage(event.target.value as Language)
} }
/** /**
* Theme toggler handler * Theme toggler handler
*/ */
const handleThemeChange = (event: SelectChangeEvent<ThemeUnion>) => { const handleThemeChange = (event: SelectChangeEvent<Theme>) => {
dispatch(setTheme(event.target.value as ThemeUnion)); setTheme(event.target.value as Theme)
} }
/** /**
* Send via WebSocket a message to update yt-dlp binary * Updates yt-dlp binary via RPC
*/ */
const updateBinary = () => { const updateBinary = () => {
client.updateExecutable().then(() => dispatch(updated())) client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
} }
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid item xs={12} md={12} lg={12}> <Grid item xs={12} md={12} lg={12}>
<Paper <Paper
sx={{ sx={{
p: 2, p: 2.5,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 240, minHeight: 240,
@@ -137,22 +154,43 @@ export default function Settings() {
<TextField <TextField
fullWidth fullWidth
label={i18n.t('serverAddressTitle')} label={i18n.t('serverAddressTitle')}
defaultValue={settings.serverAddr} defaultValue={serverAddr}
error={invalidIP} error={invalidIP}
onChange={(e) => serverAddr$.next(e.currentTarget.value)} onChange={(e) => serverAddr$.next(e.currentTarget.value)}
InputProps={{ InputProps={{
startAdornment: <InputAdornment position="start">ws://</InputAdornment>, startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
}} }}
sx={{ mb: 2 }}
/> />
</Grid> </Grid>
<Grid item xs={12} md={1}> <Grid item xs={12} md={1}>
<TextField <TextField
disabled={reverseProxy}
fullWidth fullWidth
label={i18n.t('serverPortTitle')} label={i18n.t('serverPortTitle')}
defaultValue={settings.serverPort} defaultValue={serverPort}
onChange={(e) => serverPort$.next(e.currentTarget.value)} onChange={(e) => serverPort$.next(e.currentTarget.value)}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535} error={isNaN(Number(serverPort)) || Number(serverPort) > 65535}
/>
</Grid>
<Grid item xs={12} md={12}>
<TextField
disabled={reverseProxy}
fullWidth
label={i18n.t('appTitle')}
defaultValue={appTitle}
onChange={(e) => setApptitle(e.currentTarget.value)}
error={appTitle === ''}
/>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Checkbox
defaultChecked={reverseProxy}
onChange={() => setReverseProxy(state => !state)}
/>
}
label={i18n.t('servedFromReverseProxyCheckbox')}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> />
</Grid> </Grid>
@@ -162,18 +200,15 @@ export default function Settings() {
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('languageSelect')}</InputLabel> <InputLabel>{i18n.t('languageSelect')}</InputLabel>
<Select <Select
defaultValue={settings.language} defaultValue={language}
label={i18n.t('languageSelect')} label={i18n.t('languageSelect')}
onChange={handleLanguageChange} onChange={handleLanguageChange}
> >
<MenuItem value="english">English</MenuItem> {languages.toSorted((a, b) => a.localeCompare(b)).map(l => (
<MenuItem value="spanish">Spanish</MenuItem> <MenuItem value={l} key={l}>
<MenuItem value="italian">Italian</MenuItem> {capitalize(l)}
<MenuItem value="chinese">Chinese</MenuItem> </MenuItem>
<MenuItem value="russian">Russian</MenuItem> ))}
<MenuItem value="korean">Korean</MenuItem>
<MenuItem value="japanese">Japanese</MenuItem>
<MenuItem value="catalan">Catalan</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
@@ -181,12 +216,13 @@ export default function Settings() {
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('themeSelect')}</InputLabel> <InputLabel>{i18n.t('themeSelect')}</InputLabel>
<Select <Select
defaultValue={settings.theme} defaultValue={theme}
label={i18n.t('themeSelect')} label={i18n.t('themeSelect')}
onChange={handleThemeChange} onChange={handleThemeChange}
> >
<MenuItem value="light">Light</MenuItem> <MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem> <MenuItem value="dark">Dark</MenuItem>
<MenuItem value="system">System</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
@@ -194,8 +230,8 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={cliArgs.noMTime} defaultChecked={argsBuilder.noMTime}
onChange={() => dispatch(setCliArgs(cliArgs.toggleNoMTime().toString()))} onChange={() => setCliArgs(argsBuilder.toggleNoMTime().toString())}
/> />
} }
label={i18n.t('noMTimeCheckbox')} label={i18n.t('noMTimeCheckbox')}
@@ -204,9 +240,9 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={cliArgs.extractAudio} defaultChecked={argsBuilder.extractAudio}
onChange={() => dispatch(setCliArgs(cliArgs.toggleExtractAudio().toString()))} onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
disabled={settings.formatSelection} disabled={formatSelection}
/> />
} }
label={i18n.t('extractAudioCheckbox')} label={i18n.t('extractAudioCheckbox')}
@@ -214,10 +250,10 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={settings.formatSelection} defaultChecked={formatSelection}
onChange={() => { onChange={() => {
dispatch(setCliArgs(cliArgs.disableExtractAudio().toString())) setCliArgs(argsBuilder.disableExtractAudio().toString())
dispatch(setFormatSelection(!settings.formatSelection)) setFormatSelection(!formatSelection)
}} }}
/> />
} }
@@ -231,9 +267,9 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={settings.pathOverriding} defaultChecked={!!pathOverriding}
onChange={() => { onChange={() => {
dispatch(setPathOverriding(!settings.pathOverriding)) setPathOverriding(state => !state)
}} }}
/> />
} }
@@ -242,9 +278,9 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={settings.fileRenaming} defaultChecked={fileRenaming}
onChange={() => { onChange={() => {
dispatch(setFileRenaming(!settings.fileRenaming)) setFileRenaming(state => !state)
}} }}
/> />
} }
@@ -253,9 +289,9 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={settings.enableCustomArgs} defaultChecked={enableArgs}
onChange={() => { onChange={() => {
dispatch(setEnableCustomArgs(!settings.enableCustomArgs)) setEnableArgs(state => !state)
}} }}
/> />
} }
@@ -263,12 +299,18 @@ export default function Settings() {
/> />
</Stack> </Stack>
</Grid> </Grid>
<Grid sx={{ mr: 1, mt: 3 }}>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
Cookies
</Typography>
<CookiesTextField />
</Grid>
<Grid> <Grid>
<Stack direction="row"> <Stack direction="row">
<Button <Button
sx={{ mr: 1, mt: 3 }} sx={{ mr: 1, mt: 3 }}
variant="contained" variant="contained"
onClick={() => dispatch(updated())} onClick={() => updateBinary()}
> >
{i18n.t('updateBinButton')} {i18n.t('updateBinButton')}
</Button> </Button>
@@ -278,12 +320,6 @@ export default function Settings() {
</Paper> </Paper>
</Grid> </Grid>
</Grid> </Grid>
<Snackbar
open={status.updated}
autoHideDuration={1500}
message={i18n.t('toastUpdated')}
onClose={updateBinary}
/>
</Container> </Container>
); )
} }

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
"target": "es6", "target": "ES2018",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",

View File

@@ -1,18 +1,18 @@
import react from "@vitejs/plugin-react"; import react from '@vitejs/plugin-react-swc'
import ViteYaml from '@modyfi/vite-plugin-yaml'; import million from 'million/compiler'
import { defineConfig } from 'vite'; import ViteYaml from '@modyfi/vite-plugin-yaml'
import { resolve } from 'path'; import { defineConfig } from 'vite'
export default defineConfig(() => { export default defineConfig(() => {
return { return {
plugins: [ plugins: [
million.vite({ auto: true }),
react(), react(),
ViteYaml(), ViteYaml(),
], ],
root: resolve(__dirname, '.'), base: '',
build: { build: {
emptyOutDir: true, emptyOutDir: true,
outDir: resolve(__dirname, 'dist'),
} }
} }
}) })

51
go.mod
View File

@@ -1,31 +1,38 @@
module github.com/marcopeocchi/yt-dlp-web-ui module github.com/marcopeocchi/yt-dlp-web-ui
go 1.20 go 1.22
require ( require (
github.com/goccy/go-json v0.10.2 github.com/go-chi/chi/v5 v5.0.12
github.com/gofiber/fiber/v2 v2.47.0 github.com/go-chi/cors v1.2.1
github.com/gofiber/websocket/v2 v2.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.0.0 github.com/google/uuid v1.6.0
github.com/google/uuid v1.3.0 github.com/gorilla/websocket v1.5.1
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa github.com/reactivex/rxgo/v2 v2.5.0
golang.org/x/sys v0.9.0 golang.org/x/sys v0.18.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.5
) )
require ( require (
github.com/andybalholm/brotli v1.0.5 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/fasthttp/websocket v1.5.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.16.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/philhofer/fwd v1.1.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/tinylib/msgp v1.1.8 // indirect github.com/stretchr/testify v1.9.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/teivah/onecontext v1.3.0 // indirect
github.com/valyala/fasthttp v1.48.0 // indirect golang.org/x/net v0.22.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect golang.org/x/sync v0.6.0 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.47.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
) )

184
go.sum
View File

@@ -1,95 +1,109 @@
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ=
github.com/teivah/onecontext v1.3.0 h1:tbikMhAlo6VhAuEGCvhc8HlTnpX4xTNPTOseWuhO1J0=
github.com/teivah/onecontext v1.3.0/go.mod h1:hoW1nmdPVK/0jrvGtcx8sCKYs2PiS4z0zzfdeuEVyb0=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 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.19.5/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.13.0 h1:99E8QHRoPrXN8VpS0zgAgJ5nSjpXrPKpsJIMvGL/2Oc=
modernc.org/ccgo/v4 v4.13.0/go.mod h1:Td6RI9W9G2ZpKHaJ7UeGEiB2aIpoDqLBnm4wtkbJTbQ=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.47.0 h1:BXrzId9fOOkBtS+uFQ5aZyVGmt7WcSEPrXF5Kwsho90=
modernc.org/libc v1.47.0/go.mod h1:gzCncw0a74aCiVqHeWAYHHaW//fkSHHS/3S/gfhLlCI=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE=
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

63
main.go
View File

@@ -5,32 +5,57 @@ import (
"flag" "flag"
"io/fs" "io/fs"
"log" "log"
"os"
"runtime"
"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/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
var ( var (
host string
port int port int
queueSize int
configFile string configFile string
downloadPath string downloadPath string
downloaderPath string downloaderPath string
sessionFilePath string
localDatabasePath string
requireAuth bool requireAuth bool
rpcSecret string username string
//go:embed frontend/dist password string
userFromEnv = os.Getenv("USERNAME")
passFromEnv = os.Getenv("PASSWORD")
logFile string
enableFileLogging bool
//go:embed frontend/dist/index.html
//go:embed frontend/dist/assets/*
frontend embed.FS frontend embed.FS
) )
func init() { func init() {
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
flag.StringVar(&configFile, "conf", "", "yt-dlp-WebUI config file path") flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at")
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size")
flag.StringVar(&configFile, "conf", "./config.yml", "Config file path")
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path") flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.StringVar(&sessionFilePath, "session", ".", "session file path")
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file")
flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location")
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication") flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
flag.StringVar(&rpcSecret, "secret", "", "Secret required for auth") flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
flag.StringVar(&password, "pass", passFromEnv, "Password required for auth")
flag.Parse() flag.Parse()
} }
@@ -44,16 +69,28 @@ func main() {
c := config.Instance() c := config.Instance()
c.SetPort(port) c.Host = host
c.DownloadPath(downloadPath) c.Port = port
c.DownloaderPath(downloaderPath) c.QueueSize = queueSize
c.DownloadPath = downloadPath
c.DownloaderPath = downloaderPath
c.SessionFilePath = sessionFilePath
c.RequireAuth(requireAuth) c.RequireAuth = requireAuth
c.RPCSecret(rpcSecret) c.Username = username
c.Password = password
if configFile != "" { // if config file is found it will be merged with the current config struct
c.LoadFromFile(configFile) if err := c.LoadFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, err)
} }
server.RunBlocking(port, frontend) server.RunBlocking(&server.RunConfig{
Host: c.Host,
Port: c.Port,
App: frontend,
DBPath: localDatabasePath,
FileLogging: enableFileLogging,
LogFile: logFile,
})
} }

View File

@@ -1,7 +1,5 @@
package cli package cli
import "fmt"
const ( const (
// FG // FG
Red = "\033[31m" Red = "\033[31m"
@@ -15,9 +13,5 @@ const (
BgRed = "\033[1;41m" BgRed = "\033[1;41m"
BgBlue = "\033[1;44m" BgBlue = "\033[1;44m"
BgGreen = "\033[1;42m" BgGreen = "\033[1;42m"
BgMagenta = "\033[1;45m"
) )
// Formats a message with the specified ascii escape code, then reset.
func Format(message string, code string) string {
return fmt.Sprintf("%s%s%s", code, message, Reset)
}

View File

@@ -7,63 +7,44 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var lock sync.Mutex type Config struct {
CurrentLogFile string
type serverConfig struct { LogPath string `yaml:"log_path"`
Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"` DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"` DownloaderPath string `yaml:"downloaderPath"`
RequireAuth bool `yaml:"require_auth"` RequireAuth bool `yaml:"require_auth"`
RPCSecret string `yaml:"rpc_secret"` Username string `yaml:"username"`
Password string `yaml:"password"`
QueueSize int `yaml:"queue_size"`
SessionFilePath string `yaml:"session_file_path"`
} }
type config struct { var (
cfg serverConfig instance *Config
} instanceOnce sync.Once
)
func (c *config) LoadFromFile(filename string) (serverConfig, error) { func Instance() *Config {
bytes, err := os.ReadFile(filename)
if err != nil {
return serverConfig{}, err
}
yaml.Unmarshal(bytes, &c.cfg)
return c.cfg, nil
}
func (c *config) GetConfig() serverConfig {
return c.cfg
}
func (c *config) SetPort(port int) {
c.cfg.Port = port
}
func (c *config) DownloadPath(path string) {
c.cfg.DownloadPath = path
}
func (c *config) DownloaderPath(path string) {
c.cfg.DownloaderPath = path
}
func (c *config) RequireAuth(value bool) {
c.cfg.RequireAuth = value
}
func (c *config) RPCSecret(secret string) {
c.cfg.RPCSecret = secret
}
var instance *config
func Instance() *config {
if instance == nil { if instance == nil {
lock.Lock() instanceOnce.Do(func() {
defer lock.Unlock() instance = &Config{}
if instance == nil { })
instance = &config{serverConfig{}}
}
} }
return instance return instance
} }
// Initialises the Config struct given its config file
func (c *Config) LoadFile(filename string) error {
fd, err := os.Open(filename)
if err != nil {
return err
}
if err := yaml.NewDecoder(fd).Decode(c); err != nil {
return err
}
return nil
}

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

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

196
server/handlers/archive.go Normal file
View File

@@ -0,0 +1,196 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
)
/*
File based operation handlers (should be moved to rest/handlers.go) or in
a entirely self-contained package
*/
type DirectoryEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
IsVideo bool `json:"isVideo"`
IsDirectory bool `json:"isDirectory"`
}
func walkDir(root string) (*[]DirectoryEntry, error) {
dirs, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var files []DirectoryEntry
for _, d := range dirs {
if !utils.IsValidEntry(d) {
continue
}
path := filepath.Join(root, d.Name())
info, err := d.Info()
if err != nil {
return nil, err
}
files = append(files, DirectoryEntry{
Path: path,
Name: d.Name(),
Size: info.Size(),
IsVideo: utils.IsVideo(d),
IsDirectory: d.IsDir(),
ModTime: info.ModTime(),
})
}
return &files, err
}
type ListRequest struct {
SubDir string `json:"subdir"`
OrderBy string `json:"orderBy"`
}
func ListDownloaded(w http.ResponseWriter, r *http.Request) {
root := config.Instance().DownloadPath
req := new(ListRequest)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files, err := walkDir(filepath.Join(root, req.SubDir))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.OrderBy == "modtime" {
sort.SliceStable(*files, func(i, j int) bool {
return (*files)[i].ModTime.After((*files)[j].ModTime)
})
}
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(files); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
type DeleteRequest = DirectoryEntry
func DeleteFile(w http.ResponseWriter, r *http.Request) {
req := new(DeleteRequest)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := os.Remove(req.Path); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode("ok")
}
func SendFile(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "id")
if path == "" {
http.Error(w, "inexistent path", http.StatusBadRequest)
return
}
path, err := url.QueryUnescape(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
decoded, err := base64.StdEncoding.DecodeString(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filename := string(decoded)
root := config.Instance().DownloadPath
// TODO: further path / file validations
if strings.Contains(filepath.Dir(filename), root) {
http.ServeFile(w, r, filename)
return
}
w.WriteHeader(http.StatusUnauthorized)
}
func DownloadFile(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "id")
if path == "" {
http.Error(w, "inexistent path", http.StatusBadRequest)
return
}
path, err := url.QueryUnescape(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
decoded, err := base64.StdEncoding.DecodeString(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filename := string(decoded)
root := config.Instance().DownloadPath
if strings.Contains(filepath.Dir(filename), root) {
w.Header().Add(
"Content-Disposition",
"inline; filename="+filepath.Base(filename),
)
w.Header().Set(
"Content-Type",
"application/octet-stream",
)
fd, err := os.Open(filename)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.Copy(w, fd)
}
w.WriteHeader(http.StatusUnauthorized)
}

67
server/handlers/login.go Normal file
View File

@@ -0,0 +1,67 @@
package handlers
import (
"encoding/json"
"net/http"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func Login(w http.ResponseWriter, r *http.Request) {
req := new(LoginRequest)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var (
username = config.Instance().Username
password = config.Instance().Password
)
if username != req.Username || password != req.Password {
http.Error(w, "invalid username or password", http.StatusBadRequest)
return
}
expiresAt := time.Now().Add(time.Hour * 24 * 30)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"expiresAt": expiresAt,
"username": req.Username,
})
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(tokenString); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: utils.TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
Expires: time.Now(),
Value: "",
Path: "/",
}
http.SetCookie(w, cookie)
}

View File

@@ -1,12 +1,13 @@
package server package internal
import "time" import "time"
// Progress for the Running call // Progress for the Running call
type DownloadProgress struct { type DownloadProgress struct {
Status int `json:"process_status"`
Percentage string `json:"percentage"` Percentage string `json:"percentage"`
Speed float32 `json:"speed"` Speed float32 `json:"speed"`
ETA int `json:"eta"` ETA float32 `json:"eta"`
} }
// Used to deser the yt-dlp -J output // Used to deser the yt-dlp -J output
@@ -19,6 +20,7 @@ type DownloadInfo struct {
VCodec string `json:"vcodec"` VCodec string `json:"vcodec"`
ACodec string `json:"acodec"` ACodec string `json:"acodec"`
Extension string `json:"ext"` Extension string `json:"ext"`
OriginalURL string `json:"original_url"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -48,6 +50,8 @@ type ProcessResponse struct {
Id string `json:"id"` Id string `json:"id"`
Progress DownloadProgress `json:"progress"` Progress DownloadProgress `json:"progress"`
Info DownloadInfo `json:"info"` Info DownloadInfo `json:"info"`
Output DownloadOutput `json:"output"`
Params []string `json:"params"`
} }
// struct representing the current status of the memoryDB // struct representing the current status of the memoryDB
@@ -63,7 +67,20 @@ type AbortRequest struct {
// struct representing the intent to start a download // struct representing the intent to start a download
type DownloadRequest struct { type DownloadRequest struct {
Url string `json:"url"` Id string
URL string `json:"url"`
Path string `json:"path"`
Rename string `json:"rename"`
Params []string `json:"params"` Params []string `json:"params"`
RenameTo string `json:"renameTo"` }
// struct representing request of creating a netscape cookies file
type SetCookiesRequest struct {
Cookies string `json:"cookies"`
}
type CustomTemplate struct {
Id string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
} }

View File

@@ -1,16 +1,16 @@
package server package internal
import ( import (
"encoding/gob"
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath"
"sync" "sync"
"github.com/goccy/go-json"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
// In-Memory Thread-Safe Key-Value Storage with optional persistence // In-Memory Thread-Safe Key-Value Storage with optional persistence
@@ -20,7 +20,7 @@ type MemoryDB struct {
// Get a process pointer given its id // Get a process pointer given its id
func (m *MemoryDB) Get(id string) (*Process, error) { func (m *MemoryDB) Get(id string) (*Process, error) {
entry, ok := db.table.Load(id) entry, ok := m.table.Load(id)
if !ok { if !ok {
return nil, errors.New("no process found for the given key") return nil, errors.New("no process found for the given key")
} }
@@ -29,29 +29,34 @@ func (m *MemoryDB) Get(id string) (*Process, error) {
// Store a pointer of a process and return its id // Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string { func (m *MemoryDB) Set(process *Process) string {
id := uuid.Must(uuid.NewRandom()).String() id := uuid.NewString()
db.table.Store(id, process) m.table.Store(id, process)
process.Id = id
return id return id
} }
// Update a process info/metadata, given the process id // Update a process info/metadata, given the process id
//
// Deprecated: will be removed anytime soon.
func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error { func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error {
entry, ok := db.table.Load(id) entry, ok := m.table.Load(id)
if ok { if ok {
entry.(*Process).Info = info entry.(*Process).Info = info
db.table.Store(id, entry) m.table.Store(id, entry)
return nil return nil
} }
return fmt.Errorf("can't update row with id %s", id) return fmt.Errorf("can't update row with id %s", id)
} }
// Update a process progress data, given the process id // Update a process progress data, given the process id
// Used for updating completition percentage or ETA // Used for updating completition percentage or ETA.
//
// Deprecated: will be removed anytime soon.
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error { func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
entry, ok := db.table.Load(id) entry, ok := m.table.Load(id)
if ok { if ok {
entry.(*Process).Progress = progress entry.(*Process).Progress = progress
db.table.Store(id, entry) m.table.Store(id, entry)
return nil return nil
} }
return fmt.Errorf("can't update row with id %s", id) return fmt.Errorf("can't update row with id %s", id)
@@ -59,12 +64,12 @@ func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
// Removes a process progress, given the process id // Removes a process progress, given the process id
func (m *MemoryDB) Delete(id string) { func (m *MemoryDB) Delete(id string) {
db.table.Delete(id) m.table.Delete(id)
} }
func (m *MemoryDB) Keys() *[]string { func (m *MemoryDB) Keys() *[]string {
running := []string{} running := []string{}
db.table.Range(func(key, value any) bool { m.table.Range(func(key, value any) bool {
running = append(running, key.(string)) running = append(running, key.(string))
return true return true
}) })
@@ -74,11 +79,13 @@ func (m *MemoryDB) Keys() *[]string {
// Returns a slice of all currently stored processes progess // Returns a slice of all currently stored processes progess
func (m *MemoryDB) All() *[]ProcessResponse { func (m *MemoryDB) All() *[]ProcessResponse {
running := []ProcessResponse{} running := []ProcessResponse{}
db.table.Range(func(key, value any) bool { m.table.Range(func(key, value any) bool {
running = append(running, ProcessResponse{ running = append(running, ProcessResponse{
Id: key.(string), Id: key.(string),
Info: value.(*Process).Info, Info: value.(*Process).Info,
Progress: value.(*Process).Progress, Progress: value.(*Process).Progress,
Output: value.(*Process).Output,
Params: value.(*Process).Params,
}) })
return true return true
}) })
@@ -86,36 +93,57 @@ func (m *MemoryDB) All() *[]ProcessResponse {
} }
// WIP: Persist the database in a single file named "session.dat" // WIP: Persist the database in a single file named "session.dat"
func (m *MemoryDB) Persist() { func (m *MemoryDB) Persist() error {
running := m.All() running := m.All()
session, err := json.Marshal(Session{ sf := filepath.Join(config.Instance().SessionFilePath, "session.dat")
Processes: *running,
}) fd, err := os.Create(sf)
if err != nil { if err != nil {
log.Println(cli.Red, "Failed to persist database", cli.Reset) return errors.Join(errors.New("failed to persist session"), err)
return
} }
err = os.WriteFile("session.dat", session, 0700) session := Session{
if err != nil { Processes: *running,
log.Println(cli.Red, "Failed to persist database", cli.Reset)
} }
err = gob.NewEncoder(fd).Encode(session)
if err != nil {
return errors.Join(errors.New("failed to persist session"), err)
}
return nil
} }
// WIP: Restore a persisted state // WIP: Restore a persisted state
func (m *MemoryDB) Restore() { func (m *MemoryDB) Restore(logger *slog.Logger) {
feed, _ := os.ReadFile("session.dat") fd, err := os.Open("session.dat")
if err != nil {
return
}
session := Session{} session := Session{}
json.Unmarshal(feed, &session)
err = gob.NewDecoder(fd).Decode(&session)
if err != nil {
return
}
for _, proc := range session.Processes { for _, proc := range session.Processes {
db.table.Store(proc.Id, &Process{ restored := &Process{
id: proc.Id, Id: proc.Id,
url: proc.Info.URL, Url: proc.Info.URL,
Info: proc.Info, Info: proc.Info,
Progress: proc.Progress, Progress: proc.Progress,
mem: m, Output: proc.Output,
}) Params: proc.Params,
Logger: logger,
}
m.table.Store(proc.Id, restored)
if restored.Progress.Percentage != "-1" {
go restored.Start()
}
} }
} }

View File

@@ -0,0 +1,63 @@
package internal
import (
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
type MessageQueue struct {
producerCh chan *Process
consumerCh chan struct{}
}
// Creates a new message queue.
// By default it will be created with a size equals to nthe number of logical
// CPU cores.
// The queue size can be set via the qs flag.
func NewMessageQueue() *MessageQueue {
size := config.Instance().QueueSize
if size <= 0 {
panic("invalid queue size")
}
return &MessageQueue{
producerCh: make(chan *Process, size),
consumerCh: make(chan struct{}, size),
}
}
// Publish a message to the queue and set the task to a peding state.
func (m *MessageQueue) Publish(p *Process) {
p.SetPending()
go p.SetMetadata()
m.producerCh <- p
}
// Publish a message to the queue and set the task to a peding state.
// ENSURE P IS PART OF A PLAYLIST
// Needs a further review
func (m *MessageQueue) PublishPlaylistEntry(p *Process) {
m.producerCh <- p
}
// Setup the consumer listener which subscribes to the changes to the producer
// channel and triggers the "download" action.
func (m *MessageQueue) Subscriber() {
for msg := range m.producerCh {
m.consumerCh <- struct{}{}
go func(p *Process) {
p.Start()
<-m.consumerCh
}(msg)
}
}
// Empties the message queue
func (m *MessageQueue) Empty() {
for range m.producerCh {
<-m.producerCh
}
for range m.consumerCh {
<-m.consumerCh
}
}

104
server/internal/playlist.go Normal file
View File

@@ -0,0 +1,104 @@
package internal
import (
"encoding/json"
"errors"
"log/slog"
"os/exec"
"strings"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
type metadata struct {
Entries []DownloadInfo `json:"entries"`
Count int `json:"playlist_count"`
PlaylistTitle string `json:"title"`
Type string `json:"_type"`
}
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger *slog.Logger) error {
var (
downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "-J")
)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
m := metadata{}
err = cmd.Start()
if err != nil {
return err
}
logger.Info("decoding metadata", slog.String("url", req.URL))
err = json.NewDecoder(stdout).Decode(&m)
if err != nil {
return err
}
logger.Info("decoded metadata", slog.String("url", req.URL))
if m.Type == "" {
cmd.Wait()
return errors.New("probably not a valid URL")
}
if m.Type == "playlist" {
logger.Info(
"playlist detected",
slog.String("url", req.URL),
slog.Int("count", m.Count),
)
for i, meta := range m.Entries {
delta := time.Second.Microseconds() * int64(i+1)
// detect playlist title from metadata since each playlist entry will be
// treated as an individual download
req.Rename = strings.Replace(
req.Rename,
"%(playlist_title)s",
m.PlaylistTitle,
1,
)
proc := &Process{
Url: meta.OriginalURL,
Progress: DownloadProgress{},
Output: DownloadOutput{
Filename: req.Rename,
},
Info: meta,
Params: req.Params,
}
proc.Info.URL = meta.OriginalURL
proc.Info.CreatedAt = time.Now().Add(time.Duration(delta))
db.Set(proc)
proc.SetPending()
mq.PublishPlaylistEntry(proc)
}
err = cmd.Wait()
return err
}
proc := &Process{
Url: req.URL,
Params: req.Params,
Logger: logger,
}
mq.Publish(proc)
logger.Info("sending new process to message queue", slog.String("url", proc.Url))
return cmd.Wait()
}

339
server/internal/process.go Normal file
View File

@@ -0,0 +1,339 @@
package internal
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"slices"
"sync"
"syscall"
"log"
"os"
"os/exec"
"strings"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
)
const template = `download:
{
"eta":%(progress.eta)s,
"percentage":"%(progress._percent_str)s",
"speed":%(progress.speed)s
}`
const (
StatusPending = iota
StatusDownloading
StatusCompleted
StatusErrored
)
type ProgressTemplate struct {
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
Size string `json:"size"`
Eta float32 `json:"eta"`
}
// Process descriptor
type Process struct {
Id string
Url string
Params []string
Info DownloadInfo
Progress DownloadProgress
Output DownloadOutput
proc *os.Process
Logger *slog.Logger
}
type DownloadOutput struct {
Path string
Filename string
}
// Starts spawns/forks a new yt-dlp process and parse its stdout.
// The process is spawned to outputting a custom progress text that
// Resembles a JSON Object in order to Unmarshal it later.
// This approach is anyhow not perfect: quotes are not escaped properly.
// Each process is not identified by its PID but by a UUIDv4
func (p *Process) Start() {
// escape bash variable escaping and command piping, you'll never know
// what they might come with...
p.Params = slices.DeleteFunc(p.Params, func(e string) bool {
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
return match
})
p.Params = slices.DeleteFunc(p.Params, func(e string) bool {
return e == ""
})
out := DownloadOutput{
Path: config.Instance().DownloadPath,
Filename: "%(title)s.%(ext)s",
}
if p.Output.Path != "" {
out.Path = p.Output.Path
}
if p.Output.Filename != "" {
out.Filename = p.Output.Filename
}
buildFilename(&p.Output)
params := []string{
strings.Split(p.Url, "?list")[0], //no playlist
"--newline",
"--no-colors",
"--no-playlist",
"--progress-template",
strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(template),
}
// if user asked to manually override the output path...
if !(slices.Contains(params, "-P") || slices.Contains(params, "--paths")) {
params = append(params, "-o")
params = append(params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
}
params = append(params, p.Params...)
// ----------------- main block ----------------- //
cmd := exec.Command(config.Instance().DownloaderPath, params...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
r, err := cmd.StdoutPipe()
if err != nil {
p.Logger.Error(
"failed to connect to stdout",
slog.String("err", err.Error()),
)
panic(err)
}
scan := bufio.NewScanner(r)
err = cmd.Start()
if err != nil {
p.Logger.Error(
"failed to start yt-dlp process",
slog.String("err", err.Error()),
)
panic(err)
}
p.proc = cmd.Process
// --------------- progress block --------------- //
var (
sourceChan = make(chan []byte)
doneChan = make(chan struct{})
)
// spawn a goroutine that does the dirty job of parsing the stdout
// filling the channel with as many stdout line as yt-dlp produces (producer)
go func() {
defer func() {
r.Close()
p.Complete()
doneChan <- struct{}{}
close(sourceChan)
close(doneChan)
}()
for scan.Scan() {
sourceChan <- scan.Bytes()
}
}()
// Slows down the unmarshal operation to every 500ms
go func() {
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
stdout := ProgressTemplate{}
err := json.Unmarshal(event, &stdout)
if err == nil {
p.Progress = DownloadProgress{
Status: StatusDownloading,
Percentage: stdout.Percentage,
Speed: stdout.Speed,
ETA: stdout.Eta,
}
p.Logger.Info("progress",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("percentege", stdout.Percentage),
)
}
})
}()
// ------------- end progress block ------------- //
cmd.Wait()
}
// Keep process in the memoryDB but marks it as complete
// Convention: All completed processes has progress -1
// and speed 0 bps.
func (p *Process) Complete() {
p.Progress = DownloadProgress{
Status: StatusCompleted,
Percentage: "-1",
Speed: 0,
ETA: 0,
}
p.Logger.Info("finished",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
)
}
// Kill a process and remove it from the memory
func (p *Process) Kill() error {
// yt-dlp uses multiple child process the parent process
// has been spawned with setPgid = true. To properly kill
// all subprocesses a SIGTERM need to be sent to the correct
// process group
if p.proc != nil {
pgid, err := syscall.Getpgid(p.proc.Pid)
if err != nil {
return err
}
err = syscall.Kill(-pgid, syscall.SIGTERM)
p.Logger.Info("killed process", slog.String("id", p.Id))
return err
}
return nil
}
// Returns the available format for this URL
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output()
if err != nil {
return DownloadFormats{}, err
}
info := DownloadFormats{URL: p.Url}
best := Format{}
var (
wg sync.WaitGroup
decodingError error
)
wg.Add(2)
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, "Formats", cli.Reset,
p.Url,
)
p.Logger.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", p.Url),
)
go func() {
decodingError = json.Unmarshal(stdout, &info)
wg.Done()
}()
go func() {
decodingError = json.Unmarshal(stdout, &best)
wg.Done()
}()
wg.Wait()
if decodingError != nil {
return DownloadFormats{}, err
}
info.Best = best
return info, nil
}
func (p *Process) SetPending() {
// Since video's title isn't available yet, fill in with the URL.
p.Info = DownloadInfo{
URL: p.Url,
Title: p.Url,
CreatedAt: time.Now(),
}
p.Progress.Status = StatusPending
}
func (p *Process) SetMetadata() error {
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe()
if err != nil {
p.Logger.Error("failed retrieving info",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", err.Error()),
)
return err
}
info := DownloadInfo{
URL: p.Url,
CreatedAt: time.Now(),
}
err = cmd.Start()
if err != nil {
return err
}
p.Logger.Info("retrieving metadata",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
)
err = json.NewDecoder(stdout).Decode(&info)
if err != nil {
return err
}
p.Info = info
p.Progress.Status = StatusPending
err = cmd.Wait()
return err
}
func (p *Process) getShortId() string {
return strings.Split(p.Id, "-")[0]
}
func buildFilename(o *DownloadOutput) {
if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") {
o.Filename += ".%(ext)s"
}
o.Filename = strings.Replace(
o.Filename,
".%(ext)s.%(ext)s",
".%(ext)s",
1,
)
}

View File

@@ -9,13 +9,19 @@ type Stack[T any] struct {
count int count int
} }
func (s *Stack[T]) Push(n *Node[T]) { func NewStack[T any]() *Stack[T] {
return &Stack[T]{
Nodes: make([]*Node[T], 10),
}
}
func (s *Stack[T]) Push(val T) {
if s.count >= len(s.Nodes) { if s.count >= len(s.Nodes) {
Nodes := make([]*Node[T], len(s.Nodes)*2) Nodes := make([]*Node[T], len(s.Nodes)*2)
copy(Nodes, s.Nodes) copy(Nodes, s.Nodes)
s.Nodes = Nodes s.Nodes = Nodes
} }
s.Nodes[s.count] = n s.Nodes[s.count] = &Node[T]{Value: val}
s.count++ s.count++
} }

View File

@@ -0,0 +1,80 @@
package logging
import (
"compress/gzip"
"io"
"os"
"sync"
"time"
)
// implements io.Writer interface
type LogRotateWriter struct {
mu sync.Mutex
fd *os.File
filename string
}
func NewRotableLogger(filename string) (*LogRotateWriter, error) {
fd, err := os.Create(filename)
if err != nil {
return nil, err
}
w := &LogRotateWriter{filename: filename, fd: fd}
return w, nil
}
func (w *LogRotateWriter) Write(b []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.fd.Write(b)
}
func (w *LogRotateWriter) Rotate() error {
var err error
w.mu.Lock()
gzFile, err := os.Create(w.filename + "." + time.Now().Format(time.RFC3339) + ".gz")
if err != nil {
return err
}
data, err := io.ReadAll(w.fd)
if err != nil {
return err
}
defer func() {
w.mu.Unlock()
w.gzipLog(gzFile, &data)
}()
_, err = os.Stat(w.filename)
if err != nil {
return err
}
if w.fd != nil {
err = w.fd.Close()
w.fd = nil
if err != nil {
return err
}
}
err = os.Remove(w.filename)
if err != nil {
return err
}
w.fd, err = os.Create(w.filename)
return err
}
func (w *LogRotateWriter) gzipLog(wr io.Writer, data *[]byte) error {
if _, err := gzip.NewWriter(wr).Write(*data); err != nil {
return err
}
return nil
}

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

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

View File

@@ -0,0 +1,31 @@
package logging
import (
"time"
"github.com/reactivex/rxgo/v2"
)
var (
logsChan = make(chan rxgo.Item, 100)
logsObservable = rxgo.
FromChannel(logsChan, rxgo.WithBackPressureStrategy(rxgo.Drop)).
BufferWithTime(rxgo.WithDuration(time.Millisecond * 500))
)
type ObservableLogger struct{}
func NewObservableLogger() *ObservableLogger {
return &ObservableLogger{}
}
func (o *ObservableLogger) Write(p []byte) (n int, err error) {
go func() {
logsChan <- rxgo.Of(string(p))
}()
n = len(p)
err = nil
return
}

15
server/middleware/cors.go Normal file
View File

@@ -0,0 +1,15 @@
package middlewares
import "net/http"
// Middleware for applying CORS policy for ALL hosts and for
// allowing ALL request headers.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Allow-Credentials", "true")
next.ServeHTTP(w, r)
})
}

View File

@@ -1,31 +1,21 @@
package middlewares package middlewares
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"os" "os"
"time" "time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
const ( func validateToken(tokenValue string) error {
TOKEN_COOKIE_NAME = "jwt" if tokenValue == "" {
) return errors.New("invalid token")
var Authenticated = func(c *fiber.Ctx) error {
if !config.Instance().GetConfig().RequireAuth {
return c.Next()
} }
cookie := c.Cookies(TOKEN_COOKIE_NAME) token, _ := jwt.Parse(tokenValue, func(t *jwt.Token) (interface{}, error) {
if cookie == "" {
return c.Status(fiber.StatusUnauthorized).SendString("invalid token")
}
token, _ := jwt.Parse(cookie, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
} }
@@ -36,15 +26,34 @@ var Authenticated = func(c *fiber.Ctx) error {
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string)) expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
if err != nil { if err != nil {
return c.SendStatus(fiber.StatusInternalServerError) return err
} }
if time.Now().After(expiresAt) { if time.Now().After(expiresAt) {
return c.Status(fiber.StatusBadRequest).SendString("expired token") return errors.New("token expired")
} }
} else { } else {
return c.Status(fiber.StatusUnauthorized).SendString("invalid token") return errors.New("invalid token")
} }
return c.Next() return nil
}
// Authentication does NOT use http-Only cookies since there's not risk for XSS
// By exposing the server through https it's completely safe to use httpheaders
func Authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-Authentication")
if token == "" {
token = r.URL.Query().Get("token")
}
if err := validateToken(token); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
} }

View File

@@ -1,210 +0,0 @@
package server
import (
"bufio"
"fmt"
"regexp"
"syscall"
"github.com/goccy/go-json"
"log"
"os"
"os/exec"
"strings"
"time"
"github.com/marcopeocchi/fazzoletti/slices"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
)
const template = `download:
{
"eta":%(progress.eta)s,
"percentage":"%(progress._percent_str)s",
"speed":%(progress.speed)s
}`
var (
cfg = config.Instance()
)
type ProgressTemplate struct {
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
Size string `json:"size"`
Eta int `json:"eta"`
}
// Process descriptor
type Process struct {
id string
url string
params []string
Info DownloadInfo
Progress DownloadProgress
mem *MemoryDB
proc *os.Process
}
type downloadOutput struct {
path string
filaneme string
}
// Starts spawns/forks a new yt-dlp process and parse its stdout.
// The process is spawned to outputting a custom progress text that
// Resembles a JSON Object in order to Unmarshal it later.
// This approach is anyhow not perfect: quotes are not escaped properly.
// Each process is not identified by its PID but by a UUIDv2
func (p *Process) Start(path, filename string) {
// escape bash variable escaping and command piping, you'll never know
// what they might come with...
p.params = slices.Filter(p.params, func(e string) bool {
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
return !match
})
out := downloadOutput{
path: cfg.GetConfig().DownloadPath,
filaneme: "%(title)s.%(ext)s",
}
if path != "" {
out.path = path
}
if filename != "" {
out.filaneme = filename + ".%(ext)s"
}
params := append([]string{
strings.Split(p.url, "?list")[0], //no playlist
"--newline",
"--no-colors",
"--no-playlist",
"--progress-template", strings.ReplaceAll(template, "\n", ""),
"-o",
fmt.Sprintf("%s/%s", out.path, out.filaneme),
}, p.params...)
// ----------------- main block ----------------- //
cmd := exec.Command(cfg.GetConfig().DownloaderPath, params...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
r, err := cmd.StdoutPipe()
if err != nil {
log.Panicln(err)
}
scan := bufio.NewScanner(r)
err = cmd.Start()
if err != nil {
log.Panicln(err)
}
p.id = p.mem.Set(p)
p.proc = cmd.Process
// ----------------- info block ----------------- //
// spawn a goroutine that retrieves the info for the download
go func() {
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.url, "-J")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.Output()
if err != nil {
log.Println("Cannot retrieve info for", p.url)
}
info := DownloadInfo{
URL: p.url,
CreatedAt: time.Now(),
}
json.Unmarshal(stdout, &info)
p.mem.UpdateInfo(p.id, info)
}()
// --------------- progress block --------------- //
// unbuffered channel connected to stdout
eventChan := make(chan string)
// spawn a goroutine that does the dirty job of parsing the stdout
// filling the channel with as many stdout line as yt-dlp produces (producer)
go func() {
defer r.Close()
defer p.Complete()
for scan.Scan() {
eventChan <- scan.Text()
}
cmd.Wait()
}()
// do the unmarshal operation every 250ms (consumer)
go rx.Debounce(time.Millisecond*250, eventChan, func(text string) {
stdout := ProgressTemplate{}
err := json.Unmarshal([]byte(text), &stdout)
if err == nil {
p.mem.UpdateProgress(p.id, DownloadProgress{
Percentage: stdout.Percentage,
Speed: stdout.Speed,
ETA: stdout.Eta,
})
shortId := strings.Split(p.id, "-")[0]
log.Printf("[%s] %s %s\n", shortId, p.url, p.Progress.Percentage)
}
})
// ------------- end progress block ------------- //
}
// Keep process in the memoryDB but marks it as complete
// Convention: All completed processes has progress -1
// and speed 0 bps.
func (p *Process) Complete() {
p.mem.UpdateProgress(p.id, DownloadProgress{
Percentage: "-1",
Speed: 0,
ETA: 0,
})
}
// Kill a process and remove it from the memory
func (p *Process) Kill() error {
p.mem.Delete(p.id)
// yt-dlp uses multiple child process the parent process
// has been spawned with setPgid = true. To properly kill
// all subprocesses a SIGTERM need to be sent to the correct
// process group
if p.proc != nil {
pgid, err := syscall.Getpgid(p.proc.Pid)
if err != nil {
return err
}
err = syscall.Kill(-pgid, syscall.SIGTERM)
log.Println("Killed process", p.id)
return err
}
return nil
}
// Returns the available format for this URL
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.url, "-J")
stdout, err := cmd.Output()
if err != nil {
return DownloadFormats{}, err
}
cmd.Wait()
info := DownloadFormats{URL: p.url}
best := Format{}
json.Unmarshal(stdout, &info)
json.Unmarshal(stdout, &best)
info.Best = best
return info, nil
}

35
server/rest/container.go Normal file
View File

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

View File

@@ -1,195 +1,178 @@
package rest package rest
import ( import (
"encoding/hex" "encoding/json"
"errors"
"net/http" "net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gofiber/fiber/v2" "github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
) )
const ( type Handler struct {
TOKEN_COOKIE_NAME = "jwt" service *Service
)
type DirectoryEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
SHASum string `json:"shaSum"`
ModTime time.Time `json:"modTime"`
IsVideo bool `json:"isVideo"`
IsDirectory bool `json:"isDirectory"`
} }
func walkDir(root string) (*[]DirectoryEntry, error) { /*
files := []DirectoryEntry{} REST version of the JSON-RPC interface
*/
dirs, err := os.ReadDir(root) func (h *Handler) Exec() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
req := internal.DownloadRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
id, err := h.service.Exec(req)
if err != nil { if err != nil {
return nil, err http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
for _, d := range dirs { err = json.NewEncoder(w).Encode(id)
if !utils.IsValidEntry(d) {
continue
}
path := filepath.Join(root, d.Name())
info, err := d.Info()
if err != nil { if err != nil {
return nil, err http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
} }
files = append(files, DirectoryEntry{ func (h *Handler) Running() http.HandlerFunc {
Path: path, return func(w http.ResponseWriter, r *http.Request) {
Name: d.Name(), defer r.Body.Close()
Size: info.Size(),
SHASum: utils.ShaSumString(path),
IsVideo: utils.IsVideo(d),
IsDirectory: d.IsDir(),
ModTime: info.ModTime(),
})
}
return &files, err w.Header().Set("Content-Type", "application/json")
}
type ListRequest struct { res, err := h.service.Running(r.Context())
SubDir string `json:"subdir"`
OrderBy string `json:"orderBy"`
}
func ListDownloaded(ctx *fiber.Ctx) error {
root := config.Instance().GetConfig().DownloadPath
req := new(ListRequest)
err := ctx.BodyParser(req)
if err != nil { if err != nil {
return err http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
files, err := walkDir(filepath.Join(root, req.SubDir)) err = json.NewEncoder(w).Encode(res)
if err != nil { if err != nil {
return err http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
} }
if req.OrderBy == "modtime" { func (h *Handler) SetCookies() http.HandlerFunc {
sort.SliceStable(*files, func(i, j int) bool { return func(w http.ResponseWriter, r *http.Request) {
return (*files)[i].ModTime.After((*files)[j].ModTime) defer r.Body.Close()
})
}
ctx.Status(http.StatusOK) w.Header().Set("Content-Type", "application/json")
return ctx.JSON(files)
}
type DeleteRequest = DirectoryEntry req := new(internal.SetCookiesRequest)
func DeleteFile(ctx *fiber.Ctx) error { err := json.NewDecoder(r.Body).Decode(req)
req := new(DeleteRequest)
err := ctx.BodyParser(req)
if err != nil { if err != nil {
return err http.Error(w, err.Error(), http.StatusBadRequest)
return
} }
sum := utils.ShaSumString(req.Path) err = h.service.SetCookies(r.Context(), req.Cookies)
if sum != req.SHASum {
return errors.New("shasum mismatch")
}
err = os.Remove(req.Path)
if err != nil { if err != nil {
return err http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
ctx.Status(fiber.StatusOK) err = json.NewEncoder(w).Encode("ok")
return ctx.JSON("ok")
}
func SendFile(ctx *fiber.Ctx) error {
path := ctx.Params("id")
if path == "" {
return errors.New("inexistent path")
}
decoded, err := hex.DecodeString(path)
if err != nil { if err != nil {
return err http.Error(w, err.Error(), http.StatusInternalServerError)
}
} }
decodedStr := string(decoded)
root := config.Instance().GetConfig().DownloadPath
// TODO: further path / file validations
if strings.Contains(filepath.Dir(decodedStr), root) {
// ctx.Response().Header.Set(
// "Content-Disposition",
// "inline; filename="+filepath.Base(decodedStr),
// )
ctx.SendStatus(fiber.StatusOK)
return ctx.SendFile(decodedStr)
} }
return ctx.SendStatus(fiber.StatusUnauthorized) func (h *Handler) AddTemplate() http.HandlerFunc {
} return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
type LoginRequest struct { w.Header().Set("Content-Type", "application/json")
Secret string `json:"secret"`
}
func Login(ctx *fiber.Ctx) error { req := new(internal.CustomTemplate)
req := new(LoginRequest)
err := ctx.BodyParser(req) err := json.NewDecoder(r.Body).Decode(req)
if err != nil { if err != nil {
return ctx.SendStatus(fiber.StatusInternalServerError) http.Error(w, err.Error(), http.StatusBadRequest)
return
} }
if config.Instance().GetConfig().RPCSecret != req.Secret { if req.Name == "" || req.Content == "" {
return ctx.SendStatus(fiber.StatusBadRequest) http.Error(w, "Invalid template", http.StatusBadRequest)
return
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ err = h.service.SaveTemplate(r.Context(), req)
"expiresAt": time.Now().Add(time.Minute * 30),
})
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
if err != nil { if err != nil {
return ctx.SendStatus(fiber.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
ctx.Cookie(&fiber.Cookie{ err = json.NewEncoder(w).Encode("ok")
Name: TOKEN_COOKIE_NAME, if err != nil {
HTTPOnly: true, http.Error(w, err.Error(), http.StatusInternalServerError)
Secure: false, }
Expires: time.Now().Add(time.Hour * 24 * 30), // 30 days }
Value: tokenString,
Path: "/",
})
return ctx.SendStatus(fiber.StatusOK)
} }
func Logout(ctx *fiber.Ctx) error { func (h *Handler) GetTemplates() http.HandlerFunc {
ctx.Cookie(&fiber.Cookie{ return func(w http.ResponseWriter, r *http.Request) {
Name: TOKEN_COOKIE_NAME, defer r.Body.Close()
HTTPOnly: true,
Secure: false,
Expires: time.Now(),
Value: "",
Path: "/",
})
return ctx.SendStatus(fiber.StatusOK) w.Header().Set("Content-Type", "application/json")
templates, err := h.service.GetTemplates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(templates)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) DeleteTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
id := chi.URLParam(r, "id")
err := h.service.DeleteTemplate(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) GetVersion() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
version, err := h.service.GetVersion(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(version); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
} }

36
server/rest/provider.go Normal file
View File

@@ -0,0 +1,36 @@
package rest
import (
"database/sql"
"sync"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
var (
service *Service
handler *Handler
serviceOnce sync.Once
handlerOnce sync.Once
)
func ProvideService(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) *Service {
serviceOnce.Do(func() {
service = &Service{
mdb: mdb,
db: db,
mq: mq,
}
})
return service
}
func ProvideHandler(svc *Service) *Handler {
handlerOnce.Do(func() {
handler = &Handler{
service: svc,
}
})
return handler
}

143
server/rest/service.go Normal file
View File

@@ -0,0 +1,143 @@
package rest
import (
"context"
"database/sql"
"errors"
"log/slog"
"os"
"os/exec"
"time"
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
type Service struct {
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
logger *slog.Logger
}
func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
p := &internal.Process{
Url: req.URL,
Params: req.Params,
Output: internal.DownloadOutput{
Path: req.Path,
Filename: req.Rename,
},
Logger: s.logger,
}
id := s.mdb.Set(p)
s.mq.Publish(p)
return id, nil
}
func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, error) {
select {
case <-ctx.Done():
return nil, errors.New("context cancelled")
default:
return s.mdb.All(), nil
}
}
func (s *Service) SetCookies(ctx context.Context, cookies string) error {
fd, err := os.Create("cookies.txt")
if err != nil {
return err
}
defer fd.Close()
fd.WriteString(cookies)
return nil
}
func (s *Service) SaveTemplate(ctx context.Context, template *internal.CustomTemplate) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(
ctx,
"INSERT INTO templates (id, name, content) VALUES (?, ?, ?)",
uuid.NewString(),
template.Name,
template.Content,
)
return err
}
func (s *Service) GetTemplates(ctx context.Context) (*[]internal.CustomTemplate, error) {
conn, err := s.db.Conn(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
rows, err := conn.QueryContext(ctx, "SELECT * FROM templates")
if err != nil {
return nil, err
}
defer rows.Close()
templates := make([]internal.CustomTemplate, 0)
for rows.Next() {
t := internal.CustomTemplate{}
err := rows.Scan(&t.Id, &t.Name, &t.Content)
if err != nil {
return nil, err
}
templates = append(templates, t)
}
return &templates, nil
}
func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(ctx, "DELETE FROM templates WHERE id = ?", id)
return err
}
func (s *Service) GetVersion(ctx context.Context) (string, error) {
ch := make(chan string, 1)
c, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
cmd := exec.CommandContext(c, config.Instance().DownloaderPath, "--version")
go func() {
stdout, _ := cmd.Output()
ch <- string(stdout)
}()
select {
case <-c.Done():
return "", errors.New("requesting yt-dlp version took too long")
case res := <-ch:
return res, nil
}
}

34
server/rpc/container.go Normal file
View File

@@ -0,0 +1,34 @@
package rpc
import (
"log/slog"
"github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
)
// Dependency injection container.
func Container(
db *internal.MemoryDB,
mq *internal.MessageQueue,
logger *slog.Logger,
) *Service {
return &Service{
db: db,
mq: mq,
logger: logger,
}
}
// RPC service must be registered before applying this router!
func ApplyRouter() func(chi.Router) {
return func(r chi.Router) {
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Get("/ws", WebSocket)
r.Post("/http", Post)
}
}

62
server/rpc/handlers.go Normal file
View File

@@ -0,0 +1,62 @@
package rpc
import (
"io"
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// WebSockets JSON-RPC handler
func WebSocket(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer c.Close()
// notify client that conn is open and ok
c.WriteJSON(struct{ Status string }{Status: "connected"})
for {
mtype, reader, err := c.NextReader()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println(err)
break
}
res := newRequest(reader).Call()
writer, err := c.NextWriter(mtype)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println(err)
break
}
io.Copy(writer, res)
}
}
// HTTP-POST JSON-RPC handler
func Post(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
res := newRequest(r.Body).Call()
_, err := io.Copy(w, res)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

161
server/rpc/service.go Normal file
View File

@@ -0,0 +1,161 @@
package rpc
import (
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater"
)
type Service struct {
db *internal.MemoryDB
mq *internal.MessageQueue
logger *slog.Logger
}
type Running []internal.ProcessResponse
type Pending []string
type NoArgs struct{}
type Args struct {
Id string
URL string
Params []string
}
// Exec spawns a Process.
// The result of the execution is the newly spawned process Id.
func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
p := &internal.Process{
Url: args.URL,
Params: args.Params,
Output: internal.DownloadOutput{
Path: args.Path,
Filename: args.Rename,
},
Logger: s.logger,
}
s.db.Set(p)
s.mq.Publish(p)
*result = p.Id
return nil
}
// Exec spawns a Process.
// The result of the execution is the newly spawned process Id.
func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error {
err := internal.PlaylistDetect(args, s.mq, s.db, s.logger)
if err != nil {
return err
}
*result = ""
return nil
}
// Progess retrieves the Progress of a specific Process given its Id
func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error {
proc, err := s.db.Get(args.Id)
if err != nil {
return err
}
*progress = proc.Progress
return nil
}
// Progess retrieves available format for a given resource
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
var err error
p := internal.Process{Url: args.URL, Logger: s.logger}
*meta, err = p.GetFormatsSync()
return err
}
// Pending retrieves a slice of all Pending/Running processes ids
func (s *Service) Pending(args NoArgs, pending *Pending) error {
*pending = *s.db.Keys()
return nil
}
// Running retrieves a slice of all Processes progress
func (s *Service) Running(args NoArgs, running *Running) error {
*running = *s.db.All()
return nil
}
// Kill kills a process given its id and remove it from the memoryDB
func (s *Service) Kill(args string, killed *string) error {
s.logger.Info("Trying killing process with id", slog.String("id", args))
proc, err := s.db.Get(args)
if err != nil {
return err
}
if proc != nil {
err = proc.Kill()
s.db.Delete(proc.Id)
}
s.db.Delete(proc.Id)
return err
}
// KillAll kills all process unconditionally and removes them from
// the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error {
s.logger.Info("Killing all spawned processes")
keys := s.db.Keys()
var err error
for _, key := range *keys {
proc, err := s.db.Get(key)
if err != nil {
return err
}
if proc != nil {
proc.Kill()
s.db.Delete(proc.Id)
}
}
s.mq.Empty()
return err
}
// Remove a process from the db rendering it unusable if active
func (s *Service) Clear(args string, killed *string) error {
s.logger.Info("Clearing process with id", slog.String("id", args))
s.db.Delete(args)
return nil
}
// FreeSpace gets the available from package sys util
func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
freeSpace, err := sys.FreeSpace()
*free = freeSpace
return err
}
// Return a flattned tree of the download directory
func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
dfsTree, err := sys.DirectoryTree()
if dfsTree != nil {
*tree = *dfsTree
}
return err
}
// Updates the yt-dlp binary using its builtin function
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
s.logger.Info("Updating yt-dlp executable to the latest release")
err := updater.UpdateExecutable()
if err != nil {
*updated = true
return err
}
*updated = false
return err
}

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