Compare commits

...

109 Commits

Author SHA1 Message Date
46bfb1e80f Added favicon
Closes #139
2024-03-03 20:32:20 +01:00
1cfda047cb code refactoring, added router, moved download api path 2024-02-23 10:55:33 +01:00
65b0c8bc0e code refactoring 2024-02-12 12:02:23 +01:00
cc06487b0a layout refactoring 2024-02-10 09:56:50 +01:00
63b5f00320 formats selection 2024-02-09 18:20:22 +01:00
b5c627da28 download templates 2024-02-09 11:08:47 +01:00
453cd2a373 implementing download 2024-02-09 10:47:18 +01:00
e7e4d03baf comments 2024-02-07 15:39:44 +01:00
834664184b core functionalities 2024-02-07 15:23:52 +01:00
9bc8734ef0 test 2024-02-07 15:05:03 +01:00
49152aa641 core functionalities 2024-02-07 15:03:12 +01:00
6785ead452 code refactoring 2024-02-07 14:40:27 +01:00
00df98233d implemented core stores 2024-02-07 14:35:04 +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
128 changed files with 11566 additions and 1223 deletions

View File

@@ -1,15 +1,18 @@
node_modules
downloads
dist
package-lock.json
pnpm-lock.yaml
.pnpm-debug.log
.parcel-cache
.git
src/server/core/*.exe
src/server/core/yt-dlp
node_modules
.env
*.mp4
*.ytdl
*.part
*.db
build/
downloads
.DS_Store
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)
# 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.
name: Docker
on:
release:
branches: [ master ]
tags: [ 'v*.*.*' ]
types: [published]
push:
branches: [ master ]
pull_request:
branches: [ master ]
schedule:
- 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:
build:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
@@ -33,65 +16,67 @@ jobs:
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@1e95c1de343b5b0c23352d6417ee3e48d5bcd422
# v3.1.2
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19
with:
cosign-release: 'v1.13.1'
# Workaround: https://github.com/docker/build-push-action/issues/461
- name: Set up QEMU
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
- name: Set up QEMU for ARM emulation
# v2.2.0
uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7
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 }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
# v4.6.0
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175
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
id: build-and-push
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
# v4.2.1
uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
push: true
platforms: linux/amd64,linux/arm64
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
if: ${{ github.event_name != 'pull_request' }}
env:
COSIGN_EXPERIMENTAL: "true"
# This step uses the identity token to provision an ephemeral certificate
# 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)

9
.gitignore vendored
View File

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

4
.vscode/launch.json vendored
View File

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

View File

@@ -1,33 +1,30 @@
FROM golang:1.20-alpine AS build
# folder structure
WORKDIR /usr/src/yt-dlp-webui
# install core dependencies
FROM golang:alpine AS build
RUN apk update && \
apk add nodejs npm go
# copia la salsa
COPY . .
# build frontend
apk add nodejs npm
COPY . /usr/src/yt-dlp-webui
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 :)
FROM alpine:edge
WORKDIR /downloads
VOLUME /downloads
VOLUME /downloads /config
WORKDIR /app
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
ENV JWT_SECRET=secret
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,10 @@ all:
CGO_ENABLED=0 go build -o yt-dlp-webui main.go
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
mv yt-dlp-webui* build
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -o build/yt-dlp-webui_linux-arm main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go
clean:
rm -rf build

View File

@@ -1,3 +1,8 @@
> [!IMPORTANT]
> Major frontend refactoring in progress.
> I won't add features or fix minor issues until completition.
---
# yt-dlp Web UI
A not so terrible web ui for yt-dlp.
@@ -15,14 +20,13 @@ The bottleneck remains yt-dlp startup time.
docker pull marcobaobao/yt-dlp-webui
```
```sh
# latest stable
# latest dev
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
# latest dev version
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
```
![output](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/82bcecaf-4ced-441f-9384-105653abfae4)
![](https://i.ibb.co/RCpfg7q/image.png)
![](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
Stream or download your content, easily.
@@ -75,20 +79,10 @@ The currently avaible settings are:
## Format selection
![fs1](https://i.ibb.co/8dgS6ym/image.png)
This feature is disabled by default as this intended to be used to retrieve the best quality automatically.
To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag!
Future releases will have:
- ~~Multi download~~ *done*
- ~~Exctract audio~~ *done*
- ~~Format selection~~ *done*
- ~~Download archive~~ *done*
- ~~ARM Build~~ *done available through ghcr.io*
- Playlist support
## Troubleshooting
- **It says that it isn't connected/ip in the header is not defined.**
- You must set the server ip address in the settings section (gear icon).
@@ -97,7 +91,6 @@ Future releases will have:
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
```sh
# recomended for ARM and x86 devices
docker pull marcobaobao/yt-dlp-webui
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
```
@@ -107,6 +100,12 @@ Or with docker but building the container manually.
```sh
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 \
-v <your dir>:/config \ # optional
yt-dlp-webui
```
If you opt to add RPC authentication...
@@ -115,9 +114,11 @@ docker run -d \
-p 3033:3033 \
-e JWT_SECRET randomsecret
-v /path/to/downloads:/downloads \
-v /path/for/config:/config \ # optional
marcobaobao/yt-dlp-webui \
--auth \
--secret your_rpc_secret
--user your_username \
--pass your_pass
```
If you wish for limiting the download queue size...
@@ -159,12 +160,16 @@ Usage yt-dlp-webui:
yt-dlp executable path (default "yt-dlp")
-out string
Where files will be saved (default ".")
-host string
Host where server will listen at (default "0.0.0.0")
-port int
Port where server will listen at (default 3033)
-qs int
Download queue size (default 8)
-secret string
Secret required for auth
-user string
Username required for auth
-pass string
Password required for auth
```
### Config file
@@ -181,7 +186,9 @@ downloaderPath: /usr/local/bin/yt-dlp
# Optional settings
require_auth: true
rpc_secret: my_random_secret
username: my_username
password: my_random_secret
queue_size: 4
```
@@ -236,12 +243,5 @@ Just as an overview, these are the available methods:
For more information open an issue on GitHub and I will provide more info ASAP.
## FAQ
- **Will it availabe for Raspberry Pi/ generic ARM devices?**
- Yes, it's cross platform :)
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
- **Why the docker image is so heavy?**
- Originally it was 1.8GB circa, now it has been slimmed to ~340MB compressed. This is due to the fact that it encapsule a basic Alpine linux image + FFmpeg + Node.js + Python3 + yt-dlp.
- **Update**: Since Golang migration and Multi-Stage builds the Docker image is now 75MB circa. A reduction of over 400% in size :D.
## What yt-dlp-webui is not
`yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS.

5
examples/Caddyfile Normal file
View File

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

37
examples/nginx.conf Normal file
View File

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

View File

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

3249
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,28 +8,31 @@
},
"author": "marcopeocchi",
"license": "MPL-2.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.11.16",
"@mui/material": "^5.13.5",
"@fontsource/roboto": "^5.0.8",
"@fontsource/roboto-mono": "^5.0.16",
"@mui/icons-material": "^5.15.4",
"@mui/material": "^5.15.4",
"fp-ts": "^2.16.2",
"million": "^2.6.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.13.0",
"react-router-dom": "^6.21.2",
"recoil": "^0.7.7",
"rxjs": "^7.8.1",
"uuid": "^9.0.0"
"rxjs": "^7.8.1"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@types/node": "^20.3.1",
"@types/react": "^18.2.13",
"@types/react-dom": "^18.2.6",
"@types/node": "^20.11.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react-helmet": "^6.1.11",
"@types/react-router-dom": "^5.3.3",
"@types/uuid": "^9.0.2",
"@vitejs/plugin-react": "^4.0.3",
"buffer": "^6.0.3",
"typescript": "^5.1.3",
"vite": "^4.4.7"
"@vitejs/plugin-react-swc": "^3.5.0",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

2028
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

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

View File

@@ -1,7 +1,7 @@
---
languages:
english:
urlInput: YouTube or other supported service video URL
urlInput: Video URL
statusTitle: Status
statusReady: Ready
selectFormatButton: Select format
@@ -33,8 +33,124 @@ languages:
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:
urlInput: URL di YouTube o di qualsiasi altro servizio supportato
urlInput: URL Video
statusTitle: Stato
startButton: Inizia
statusReady: Pronto
@@ -65,8 +181,24 @@ languages:
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:
urlInput: YouTube 或其他受支持服务的视频网址
urlInput: 视频 URL
statusTitle: 状态
statusReady: 就绪
selectFormatButton: 选择格式
@@ -97,7 +229,23 @@ languages:
splashText: 没有正在进行的下载
archiveTitle: 归档
clipboardAction: 复制 URL 到剪贴板
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
restartAppMessage: 需要刷新页面才能生效
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
newDownloadButton: 新下载
homeButtonLabel: 主页
archiveButtonLabel: 归档
settingsButtonLabel: 设置
rpcAuthenticationLabel: RPC 身份验证
themeTogglerLabel: 主题切换
loadingLabel: 正在加载…
appTitle: App 标题
savedTemplates: 保存模板
templatesEditor: 模板编辑器
templatesEditorNameLabel: 模板名称
templatesEditorContentLabel: 模板内容
logsTitle: '日志'
awaitingLogs: '正在等待日志…'
spanish:
urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado
@@ -130,6 +278,21 @@ languages:
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:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус
@@ -162,6 +325,21 @@ languages:
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:
urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태
@@ -194,6 +372,21 @@ languages:
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:
urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態
@@ -227,6 +420,21 @@ languages:
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:
urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat
@@ -259,6 +467,21 @@ languages:
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: Статус
@@ -291,6 +514,21 @@ languages:
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
@@ -323,3 +561,18 @@ languages:
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

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

View File

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

View File

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

View File

@@ -1,16 +1,5 @@
import { atom, selector } from 'recoil'
export type Language =
| 'english'
| 'chinese'
| 'russian'
| 'italian'
| 'spanish'
| 'korean'
| 'japanese'
| 'catalan'
| 'ukrainian'
| 'polish'
import { prefersDarkMode } from '../utils'
export const languages = [
'english',
@@ -23,21 +12,27 @@ export const languages = [
'catalan',
'ukrainian',
'polish',
'german'
] as const
export type Theme = 'light' | 'dark'
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: Theme
theme: ThemeNarrowed
cliArgs: string
formatSelection: boolean
fileRenaming: boolean
pathOverriding: boolean
enableCustomArgs: boolean
listView: boolean
servedFromReverseProxy: boolean
appTitle: string
}
export const languageState = atom<Language>({
@@ -51,7 +46,7 @@ export const languageState = atom<Language>({
export const themeState = atom<Theme>({
key: 'themeStateState',
default: localStorage.getItem('theme') as Theme || 'light',
default: localStorage.getItem('theme') as Theme || 'system',
effects: [
({ onSet }) =>
onSet(l => localStorage.setItem('theme', l.toString()))
@@ -79,7 +74,7 @@ export const serverPortState = atom<number>({
export const latestCliArgumentsState = atom<string>({
key: 'latestCliArgumentsState',
default: localStorage.getItem('cli-args') || '',
default: localStorage.getItem('cli-args') || '--no-mtime',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('cli-args', a.toString()))
@@ -131,15 +126,35 @@ export const listViewState = atom({
]
})
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(serverAddressState)}:${get(serverPortState)}`
get: ({ get }) => get(servedFromReverseProxyState)
? `${get(serverAddressState)}`
: `${get(serverAddressState)}:${get(serverPortState)}`
})
export const serverURL = selector({
key: 'serverURL',
get: ({ get }) =>
`${window.location.protocol}//${get(serverAddressState)}:${get(serverPortState)}`
`${window.location.protocol}//${get(serverAddressAndPortState)}`
})
export const rpcWebSocketEndpoint = selector({
@@ -158,18 +173,40 @@ export const rpcHTTPEndpoint = selector({
}
})
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(themeState),
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

@@ -23,18 +23,20 @@ export const isDownloadingState = atom({
default: false
})
// export const freeSpaceBytesState = selector({
// key: 'freeSpaceBytesState',
// get: async ({ get }) => {
// const res = await get(rpcClientState).freeSpace()
// return res.result
// }
// })
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

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

View File

@@ -2,5 +2,5 @@ import { atom } from 'recoil'
export const loadingAtom = atom({
key: 'loadingAtom',
default: false
default: true
})

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, roundMiB } 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} />
}
<CardContent>
{download.info.title !== '' ?
<Typography gutterBottom variant="h6" component="div">
{ellipsis(download.info.title, 54)}
</Typography> :
<Skeleton />
}
<Stack direction="row" spacing={1} py={2}>
<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>
{roundMiB(download.info.filesize_approx ?? 0)}
</Typography>
<Resolution resolution={download.info.resolution} />
</Stack>
{download.progress.percentage ?
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted() ? "secondary" : "primary"}
/> :
null
}
</CardContent>
</CardActionArea>
<CardActions>
<Button
variant="contained"
size="small"
color="primary"
onClick={onStop}
>
{isCompleted() ? "Clear" : "Stop"}
</Button>
</CardActions>
</Card>
)
}
export default DownloadCard

View File

@@ -1,7 +1,9 @@
import { FileUpload } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close'
import {
Autocomplete,
Backdrop,
Box,
Button,
Checkbox,
Container,
@@ -10,12 +12,8 @@ import {
Grid,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
TextField,
styled
TextField
} from '@mui/material'
import AppBar from '@mui/material/AppBar'
import Dialog from '@mui/material/Dialog'
@@ -23,16 +21,19 @@ import Slide from '@mui/material/Slide'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import { TransitionProps } from '@mui/material/transitions'
import { Buffer } from 'buffer'
import {
FC,
Suspense,
forwardRef,
useEffect,
useMemo,
useRef,
useState,
useTransition
} from 'react'
import { useRecoilValue } from 'recoil'
import { settingsState } from '../atoms/settings'
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 { useI18n } from '../hooks/useI18n'
@@ -40,6 +41,7 @@ import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser'
import type { DLMetadata } from '../types'
import { isValidURL, toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions'
const Transition = forwardRef(function Transition(
props: TransitionProps & {
@@ -53,51 +55,51 @@ const Transition = forwardRef(function Transition(
type Props = {
open: boolean
onClose: () => void
onDownloadStart: () => void
onDownloadStart: (url: string) => void
}
export default function DownloadDialog({
open,
onClose,
onDownloadStart
}: Props) {
// recoil state
const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState)
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
const downloadTemplate = useRecoilValue(downloadTemplateState)
const savedTemplates = useRecoilValue(savedTemplatesState)
// ephemeral state
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useState('')
const [downloadPath, setDownloadPath] = useState(0)
const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [fileNameOverride, setFilenameOverride] = useState('')
const [downloadPath, setDownloadPath] = useState('')
const [filenameTemplate, setFilenameTemplate] = useRecoilState(
filenameTemplateState
)
const [url, setUrl] = useState('')
const [workingUrl, setWorkingUrl] = useState('')
const [isPlaylist, setIsPlaylist] = useState(false)
// memos
const cliArgs = useMemo(() =>
const argsBuilder = useMemo(() =>
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
)
// context
const { i18n } = useI18n()
const { client } = useRPC()
// refs
const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null)
// transitions
const [isPending, startTransition] = useTransition()
useEffect(() => {
setCustomArgs('')
}, [open])
/**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
*/
@@ -107,13 +109,13 @@ export default function DownloadDialog({
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
client.download(
immediate || url || workingUrl,
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
availableDownloadPaths[downloadPath] ?? '',
fileNameOverride,
isPlaylist,
)
client.download({
url: immediate || url || workingUrl,
args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
pathOverride: downloadPath ?? '',
renameTo: settings.fileRenaming ? filenameTemplate : '',
playlist: isPlaylist,
})
setUrl('')
setWorkingUrl('')
@@ -121,7 +123,7 @@ export default function DownloadDialog({
setTimeout(() => {
resetInput()
setDownloadFormats(undefined)
onDownloadStart()
onDownloadStart(immediate || url || workingUrl)
}, 250)
}
@@ -142,45 +144,30 @@ export default function DownloadDialog({
})
}
/**
* Update the url state whenever the input value changes
* @param e Input change event
*/
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value)
}
/**
* Update the filename override state whenever the input value changes
* @param e Input change event
*/
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilenameOverride(e.target.value)
localStorage.setItem('last-filename-override', e.target.value)
const handleFilenameTemplateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilenameTemplate(e.target.value)
}
/**
* Update the custom args state whenever the input value changes
* @param e Input change event
*/
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomArgs(e.target.value)
localStorage.setItem("last-input-args", e.target.value)
}
const parseUrlListFile = (event: any) => {
const urlList = event.target.files
const reader = new FileReader()
reader.addEventListener('load', $event => {
const base64 = $event.target?.result!.toString().split(',')[1]
Buffer.from(base64!, 'base64')
.toString()
.trimEnd()
.split('\n')
.filter(_url => isValidURL(_url))
.forEach(_url => sendUrl(_url))
})
reader.readAsDataURL(urlList[0])
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.currentTarget.files
if (!files || files.length < 1) {
return
}
const file = await files[0].text()
file
.split('\n')
.filter(u => isValidURL(u))
.forEach(u => sendUrl(u))
}
const resetInput = () => {
@@ -190,43 +177,41 @@ export default function DownloadDialog({
}
}
/* -------------------- styled components -------------------- */
const Input = styled('input')({
display: 'none',
})
return (
<div>
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isPending}
/>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={onClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Download
</Typography>
</Toolbar>
</AppBar>
<Container sx={{ my: 4 }}>
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isPending}
/>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={onClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Download
</Typography>
</Toolbar>
</AppBar>
<Box sx={{
backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Container sx={{ my: 4 }} >
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
elevation={4}
sx={{
p: 2,
display: 'flex',
@@ -248,11 +233,12 @@ export default function DownloadDialog({
endAdornment: (
<InputAdornment position="end">
<label htmlFor="icon-button-file">
<Input
<input
hidden
id="icon-button-file"
type="file"
accept=".txt"
onChange={parseUrlListFile}
onChange={e => parseUrlListFile(e)}
/>
<IconButton
color="primary"
@@ -286,14 +272,15 @@ export default function DownloadDialog({
}
{
settings.fileRenaming &&
<Grid item xs={8}>
<Grid item xs={settings.pathOverriding ? 8 : 12}>
<TextField
sx={{ mt: 1 }}
ref={customFilenameInputRef}
fullWidth
label={i18n.t('customFilename')}
variant="outlined"
value={fileNameOverride}
onChange={handleFilenameOverrideChange}
value={filenameTemplate}
onChange={handleFilenameTemplateChange}
disabled={
!isConnected ||
(settings.formatSelection && downloadFormats != null)
@@ -305,23 +292,55 @@ export default function DownloadDialog({
settings.pathOverriding &&
<Grid item xs={4}>
<FormControl fullWidth>
<InputLabel>{i18n.t('customPath')}</InputLabel>
<Select
label={i18n.t('customPath')}
defaultValue={0}
variant={'outlined'}
value={downloadPath}
onChange={(e) => setDownloadPath(Number(e.target.value))}
>
{availableDownloadPaths.map((val: string, idx: number) => (
<MenuItem key={idx} value={idx}>{val}</MenuItem>
))}
</Select>
<Autocomplete
disablePortal
options={availableDownloadPaths.map((dir) => ({ label: dir, dir }))}
autoHighlight
getOptionLabel={(option) => option.label}
onChange={(_, value) => {
setDownloadPath(value?.dir!)
}}
renderOption={(props, option) => (
<Box
component="li"
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...props}>
{option.label}
</Box>
)}
sx={{ width: '100%', mt: 1 }}
renderInput={(params) => <TextField {...params} label={i18n.t('customPath')} />}
/>
</FormControl>
</Grid>
}
</Grid>
<Suspense>
{savedTemplates.length > 0 && <ExtraDownloadOptions />}
</Suspense>
<Grid container spacing={1} pt={2} justifyContent="space-between">
<Grid item>
<Grid item>
<FormControlLabel
control={<Checkbox onChange={() => setIsPlaylist(state => !state)} />}
checked={isPlaylist}
label={i18n.t('playlistCheckbox')}
/>
</Grid>
<Grid 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>
<Button
variant="contained"
@@ -338,13 +357,6 @@ export default function DownloadDialog({
}
</Button>
</Grid>
<Grid item>
<FormControlLabel
control={<Checkbox onChange={() => setIsPlaylist(state => !state)} />}
checked={isPlaylist}
label={i18n.t('playlistCheckbox')}
/>
</Grid>
</Grid>
</Paper>
</Grid>
@@ -376,7 +388,9 @@ export default function DownloadDialog({
pickedAudioFormat={pickedAudioFormat}
/>}
</Container>
</Dialog>
</div>
</Box>
</Dialog>
)
}
}
export default DownloadDialog

View File

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

View File

@@ -1,14 +1,13 @@
import { Grid } from "@mui/material"
import { Fragment } from "react"
import { Grid } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from "../hooks/toast"
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { StackableResult } from "./StackableResult"
import DownloadCard from './DownloadCard'
const DownloadsCardView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? []
const downloads = useRecoilValue(activeDownloadsState)
const { i18n } = useI18n()
const { client } = useRPC()
@@ -21,20 +20,11 @@ const DownloadsCardView: React.FC = () => {
{
downloads.map(download => (
<Grid item xs={4} sm={8} md={6} key={download.id}>
<Fragment>
<StackableResult
url={download.info.url}
title={download.info.title}
thumbnail={download.info.thumbnail}
percentage={download.progress.percentage}
onStop={() => abort(download.id)}
onCopy={() => pushMessage(i18n.t('clipboardAction'))}
resolution={download.info.resolution ?? ''}
speed={download.progress.speed}
size={download.info.filesize_approx ?? 0}
status={download.progress.process_status}
/>
</Fragment>
<DownloadCard
download={download}
onStop={() => abort(download.id)}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/>
</Grid>
))
}

View File

@@ -18,7 +18,7 @@ import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
const DownloadsListView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? []
const downloads = useRecoilValue(activeDownloadsState)
const { client } = useRPC()
@@ -27,9 +27,14 @@ const DownloadsListView: React.FC = () => {
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
<Grid item xs={12}>
<TableContainer component={Paper} sx={{ minHeight: '100%' }} elevation={2}>
<TableContainer
component={Paper}
sx={{ minHeight: '100%' }}
elevation={2}
hidden={downloads.length === 0}
>
<Table>
<TableHead hidden={downloads.length === 0}>
<TableHead>
<TableRow>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Title</Typography>

View File

@@ -38,6 +38,14 @@ const ErrorBoundary: React.FC = () => {
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>
)
}

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

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ const LoadingBackdrop: React.FC = () => {
return (
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!isLoading}
open={isLoading}
>
<CircularProgress color="primary" />
</Backdrop>

View File

@@ -0,0 +1,91 @@
import { Box, CircularProgress, Container, Paper, Typography } from '@mui/material'
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 serverAddr = useRecoilValue(serverURL)
const { i18n } = useI18n()
const [logBuffer, setLogBuffer] = useState<string[]>([])
const boxRef = useRef<HTMLDivElement>(null)
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(-100))
boxRef.current?.scrollTo(0, boxRef.current.scrollHeight)
})
// TODO: in dev mode it breaks sse
return () => eventSource.close()
}, [eventSource])
const logEntryStyle = (data: string) => {
if (data.includes("level=ERROR")) {
return { color: 'red' }
}
if (data.includes("level=WARN")) {
return { color: 'orange' }
}
return {}
}
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Paper
sx={{
p: 2.5,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography py={1} variant="h5" color="primary">
{i18n.t('logsTitle')}
</Typography>
{(logBuffer.length === 0) && <Box sx={{
display: 'flex',
flexDirection: 'column',
justifyItems: 'center',
alignItems: 'center',
gap: 1
}}>
<CircularProgress color="primary" size={32} />
<Typography py={1} variant="subtitle2" >
{i18n.t('awaitingLogs')}
</Typography>
</Box>
}
<Box
ref={boxRef}
sx={{
fontFamily: 'Roboto Mono',
height: '75.5vh',
overflowY: 'auto',
overflowX: 'auto',
fontSize: '15px'
}}
>
{logBuffer.map((log, idx) => (
<Box key={idx} sx={logEntryStyle(log)}>
{log}
</Box>
))}
</Box>
</Paper>
</Container >
)
}
export default LogTerminal

View File

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

View File

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

View File

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

View File

@@ -1,111 +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 { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils'
type Props = {
title: string
url: string
thumbnail: string
resolution: string
percentage: string
size: number
speed: number
status: number
onStop: () => void
onCopy: () => void
}
export function StackableResult({
title,
url,
thumbnail,
resolution,
percentage,
speed,
size,
status,
onStop,
onCopy,
}: Props) {
const isCompleted = () => percentage === '-1'
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 onClick={() => {
navigator.clipboard.writeText(url)
onCopy()
}}>
{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' : mapProcessStatus(status)}
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={onStop}
>
{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,25 +1,35 @@
import { Brightness4, Brightness5 } from '@mui/icons-material'
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 { useRecoilState } from 'recoil'
import { themeState } from '../atoms/settings'
import { Theme, themeState } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
export default function ThemeToggler() {
const ThemeToggler: React.FC = () => {
const [theme, setTheme] = useRecoilState(themeState)
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 (
<ListItemButton onClick={() => {
theme === 'light'
? setTheme('dark')
: setTheme('light')
setTheme(themes[(currentTheme + 1) % themes.length])
}}>
<ListItemIcon>
{
theme === 'light'
? <Brightness4 />
: <Brightness5 />
}
{actions[theme]}
</ListItemIcon>
<ListItemText primary="Toggle theme" />
<ListItemText primary={i18n.t('themeTogglerLabel')} />
</ListItemButton>
)
}
}
export default ThemeToggler

View File

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

View File

@@ -2,6 +2,14 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
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')!)
root.render(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { pipe } from 'fp-ts/lib/function'
import type { RPCResponse } from "./types"
/**
@@ -10,31 +11,15 @@ export function validateIP(ipAddr: string): boolean {
return ipRegex.test(ipAddr)
}
/**
* Validate a domain via regex.
* The validation pass if the domain respects the following formats:
* - localhost
* - domain.tld
* - dir.domain.tld
* @param domainName
* @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'
export function validateDomain(url: string): boolean {
const urlRegex = /(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const [name, slug] = url.split('/')
return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug)
}
/**
* 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 {
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)
@@ -47,24 +32,6 @@ export function ellipsis(str: string, lim: number): string {
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 {
if (codes.length > 1) {
return codes.reduce((v, a) => ` -f ${v}+${a}`)
@@ -75,14 +42,17 @@ export function toFormatArgs(codes: string[]): string {
return ''
}
export function formatGiB(bytes: number) {
return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
}
export const formatGiB = (bytes: number) =>
`${(bytes / 1_000_000_000).toFixed(0)}GiB`
export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
export const roundMiB = (bytes: number) =>
`${(bytes / 1_000_000).toFixed(2)} MiB`
export const datetimeCompareFunc = (a: string, b: string) => new Date(a).getTime() - new Date(b).getTime()
export const formatSpeedMiB = (val: number) =>
`${roundMiB(val)}/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
@@ -101,4 +71,14 @@ export function mapProcessStatus(status: number) {
default:
return 'Pending'
}
}
}
export const prefersDarkMode = () =>
window.matchMedia('(prefers-color-scheme: dark)').matches
export const base64URLEncode = (s: string) => pipe(
s,
s => String.fromCodePoint(...new TextEncoder().encode(s)),
btoa,
encodeURIComponent
)

View File

@@ -14,6 +14,8 @@ import {
ListItemButton,
ListItemIcon,
ListItemText,
MenuItem,
MenuList,
Paper,
SpeedDial,
SpeedDialAction,
@@ -26,23 +28,31 @@ import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
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 { 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 { DeleteRequest, DirectoryEntry } from '../types'
import { roundMiB } from '../utils'
import { DirectoryEntry } from '../types'
import { base64URLEncode, roundMiB } from '../utils'
export default function Downloaded() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
const [showMenu, setShowMenu] = useState(false)
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
const serverAddr = useRecoilValue(serverURL)
const navigate = useNavigate()
const { i18n } = useI18n()
const { pushMessage } = useToast()
const [openDialog, setOpenDialog] = useState(false)
@@ -51,20 +61,24 @@ export default function Downloaded() {
const [isPending, startTransition] = useTransition()
const fetcher = () => ffetch<DirectoryEntry[]>(
`${serverAddr}/archive/downloaded`,
(d) => files$.next(d),
() => navigate('/login'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
const fetcher = () => pipe(
ffetch<DirectoryEntry[]>(
`${serverAddr}/archive/downloaded`,
{
method: 'POST',
body: JSON.stringify({
subdir: '',
})
}
),
matchW(
(e) => {
pushMessage(e, 'error')
navigate('/login')
},
body: JSON.stringify({
subdir: '',
})
}
)
(d) => files$.next(d ?? []),
)
)()
const fetcherSubfolder = (sub: string) => {
const folders = sub.startsWith('/')
@@ -80,24 +94,32 @@ export default function Downloaded() {
? ['.', ..._upperLevel].join('/')
: _upperLevel.join('/')
fetch(`${serverAddr}/archive/downloaded`, {
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subdir: relpath })
})
.then(res => res.json())
.then(data => {
files$.next(sub
pipe(
task,
matchW(
(l) => pushMessage(l, 'error'),
(r) => files$.next(sub
? [{
name: '..',
isDirectory: true,
isVideo: false,
modTime: '',
name: '..',
path: upperLevel,
}, ...data]
: data
shaSum: '',
size: 0,
}, ...r.filter(f => f.name !== '')]
: r.filter(f => f.name !== '')
)
})
)
)()
}
const selectable$ = useMemo(() => files$.pipe(
@@ -117,19 +139,24 @@ export default function Downloaded() {
: selected$.next([...selected$.value, name])
}
const deleteFile = (entry: DirectoryEntry) => pipe(
ffetch(`${serverAddr}/archive/delete`, {
method: 'POST',
body: JSON.stringify({
path: entry.path,
shaSum: entry.shaSum,
})
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => fetcher()
)
)()
const deleteSelected = () => {
Promise.all(selectable
.filter(entry => entry.selected)
.map(entry => fetch(`${serverAddr}/archive/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: entry.path,
shaSum: entry.shaSum,
} as DeleteRequest)
}))
.map(deleteFile)
).then(fetcher)
}
@@ -138,7 +165,15 @@ export default function Downloaded() {
}, [serverAddr])
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(() => {
@@ -146,18 +181,42 @@ export default function Downloaded() {
})
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Container
maxWidth="lg"
sx={{ mt: 4, mb: 4, height: '100%' }}
onClick={() => setShowMenu(false)}
>
<IconMenu
posX={menuPos.x}
posY={menuPos.y}
hide={!showMenu}
onDownload={() => {
if (currentFile) {
downloadFile(currentFile?.path)
setCurrentFile(undefined)
}
}}
onDelete={() => {
if (currentFile) {
deleteFile(currentFile)
setCurrentFile(undefined)
}
}}
/>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!(files$.observed) || isPending}
>
<CircularProgress color="primary" />
</Backdrop>
<Paper sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
onClick={() => setShowMenu(false)}
>
<Typography py={1} variant="h5" color="primary">
{i18n.t('archiveTitle')}
</Typography>
@@ -165,6 +224,12 @@ export default function Downloaded() {
{selectable.length === 0 && 'No files found'}
{selectable.map((file, idx) => (
<ListItem
onContextMenu={(e) => {
e.preventDefault()
setCurrentFile(file)
setMenuPos({ x: e.clientX, y: e.clientY })
setShowMenu(true)
}}
key={idx}
secondaryAction={
<div>
@@ -175,11 +240,13 @@ export default function Downloaded() {
{roundMiB(file.size)}
</Typography>
}
{!file.isDirectory && <Checkbox
edge="end"
checked={file.selected}
onChange={() => addSelected(file.name)}
/>}
{!file.isDirectory && <>
<Checkbox
edge="end"
checked={file.selected}
onChange={() => addSelected(file.name)}
/>
</>}
</div>
}
disablePadding
@@ -240,11 +307,15 @@ export default function Downloaded() {
</ul>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
<Button onClick={() => {
deleteSelected()
setOpenDialog(false)
}} autoFocus
<Button onClick={() => setOpenDialog(false)}>
Cancel
</Button>
<Button
onClick={() => {
deleteSelected()
setOpenDialog(false)
}}
autoFocus
>
Ok
</Button>
@@ -252,4 +323,43 @@ export default function Downloaded() {
</Dialog>
</Container>
)
}
const IconMenu: React.FC<{
posX: number
posY: number
hide: boolean
onDownload: () => void
onDelete: () => void
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
return (
<Paper sx={{
width: 320,
maxWidth: '100%',
position: 'absolute',
top: posY,
left: posX,
display: hide ? 'none' : 'block',
zIndex: (theme) => theme.zIndex.drawer + 1,
}}>
<MenuList>
<MenuItem onClick={onDownload}>
<ListItemIcon>
<DownloadIcon fontSize="small" />
</ListItemIcon>
<ListItemText>
Download
</ListItemText>
</MenuItem>
<MenuItem onClick={onDelete}>
<ListItemIcon>
<DeleteForeverIcon fontSize="small" />
</ListItemIcon>
<ListItemText>
Delete
</ListItemText>
</MenuItem>
</MenuList>
</Paper>
)
}

View File

@@ -15,11 +15,15 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
const LoginContainer = styled(Container)({
display: 'flex',
minWidth: '100%',
minHeight: '100vh',
minHeight: '85vh',
alignItems: 'center',
justifyContent: 'center',
})
@@ -33,22 +37,48 @@ const Title = styled(Typography)({
})
export default function Login() {
const [secret, setSecret] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [formHasError, setFormHasError] = useState(false)
const url = useRecoilValue(serverURL)
const navigate = useNavigate()
const { pushMessage } = useToast()
const navigateAndReload = () => {
navigate('/')
window.location.reload()
}
const login = async () => {
const res = await fetch(`${url}/auth/login`, {
const task = ffetch<string>(`${url}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
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 (
@@ -62,17 +92,23 @@ export default function Login() {
Authentication token will expire after 30 days.
</Title>
<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 />
and --secret [secret] flags.
--user [username] and --pass [password] flags.
</Title>
<TextField
id="outlined-password-input"
label="RPC secret"
type="password"
autoComplete="current-password"
label="Username"
type="text"
autoComplete="yt-dlp-webui-username"
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()}>
Submit

View File

@@ -1,5 +1,6 @@
import {
Button,
Checkbox,
Container,
FormControl,
FormControlLabel,
@@ -29,6 +30,7 @@ import {
import {
Language,
Theme,
appTitleState,
enableCustomArgsState,
fileRenamingState,
formatSelectionState,
@@ -36,10 +38,12 @@ import {
languages,
latestCliArgumentsState,
pathOverridingState,
servedFromReverseProxyState,
serverAddressState,
serverPortState,
themeState
} from '../atoms/settings'
import CookiesTextField from '../components/CookiesTextField'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
@@ -48,6 +52,7 @@ import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() {
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
@@ -55,6 +60,7 @@ export default function Settings() {
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)
@@ -80,9 +86,11 @@ export default function Settings() {
if (validateIP(addr)) {
setInvalidIP(false)
setServerAddr(addr)
pushMessage(i18n.t('restartAppMessage'), 'info')
} else if (validateDomain(addr)) {
setInvalidIP(false)
setServerAddr(addr)
pushMessage(i18n.t('restartAppMessage'), 'info')
} else {
setInvalidIP(true)
}
@@ -99,6 +107,7 @@ export default function Settings() {
)
.subscribe(port => {
setServerPort(port)
pushMessage(i18n.t('restartAppMessage'), 'info')
})
return () => sub.unsubscribe()
}, [])
@@ -151,16 +160,37 @@ export default function Settings() {
InputProps={{
startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
}}
sx={{ mb: 2 }}
/>
</Grid>
<Grid item xs={12} md={1}>
<TextField
disabled={reverseProxy}
fullWidth
label={i18n.t('serverPortTitle')}
defaultValue={serverPort}
onChange={(e) => serverPort$.next(e.currentTarget.value)}
error={isNaN(Number(serverPort)) || Number(serverPort) > 65535}
/>
</Grid>
<Grid item xs={12} md={12}>
<TextField
disabled={reverseProxy}
fullWidth
label={i18n.t('appTitle')}
defaultValue={appTitle}
onChange={(e) => setApptitle(e.currentTarget.value)}
error={appTitle === ''}
/>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Checkbox
defaultChecked={reverseProxy}
onChange={() => setReverseProxy(state => !state)}
/>
}
label={i18n.t('servedFromReverseProxyCheckbox')}
sx={{ mb: 2 }}
/>
</Grid>
@@ -192,6 +222,7 @@ export default function Settings() {
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
<MenuItem value="system">System</MenuItem>
</Select>
</FormControl>
</Grid>
@@ -268,6 +299,12 @@ export default function Settings() {
/>
</Stack>
</Grid>
<Grid sx={{ mr: 1, mt: 3 }}>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
Cookies
</Typography>
<CookiesTextField />
</Grid>
<Grid>
<Stack direction="row">
<Button

View File

@@ -0,0 +1,9 @@
import LogTerminal from '../components/LogTerminal'
const Terminal: React.FC = () => {
return (
<LogTerminal />
)
}
export default Terminal

View File

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

View File

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

41
go.mod
View File

@@ -3,14 +3,41 @@ module github.com/marcopeocchi/yt-dlp-web-ui
go 1.20
require (
github.com/go-chi/chi/v5 v5.0.10
github.com/goccy/go-json v0.10.2
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.3.0
github.com/gorilla/websocket v1.5.0
github.com/go-chi/chi/v5 v5.0.11
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.5.0
github.com/gorilla/websocket v1.5.1
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
golang.org/x/sys v0.9.0
github.com/reactivex/rxgo/v2 v2.5.0
golang.org/x/sys v0.15.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.28.0
)
require github.com/go-chi/cors v1.2.1
require (
github.com/cenkalti/backoff/v4 v4.0.0 // indirect
github.com/davecgh/go-spew v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.1.0 // indirect
github.com/stretchr/testify v1.4.0 // indirect
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/tools v0.16.1 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.38.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

106
go.sum
View File

@@ -1,20 +1,100 @@
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/cenkalti/backoff/v4 v4.0.0 h1:6VeaLF9aI+MAUQ95106HwWzYZgJJpZ4stumjj6RFYAU=
github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4=
github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 h1:BLNsFR8l/hj/oGjnJXkd4Vi3s4kQD3/3x8HSAE4bzN0=
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.38.0 h1:o4Lpk0zNDSdsjfEXnF1FGXWQ9PDi1NOdWcLP5n13FGo=
modernc.org/libc v1.38.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=

55
main.go
View File

@@ -5,36 +5,51 @@ import (
"flag"
"io/fs"
"log"
"os"
"runtime"
"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"
)
var (
port int
queueSize int
configFile string
downloadPath string
downloaderPath string
host string
port int
queueSize int
configFile string
downloadPath string
downloaderPath string
sessionFilePath string
localDatabasePath string
requireAuth bool
rpcSecret string
username string
password string
//go:embed frontend/dist
userFromEnv = os.Getenv("USERNAME")
passFromEnv = os.Getenv("PASSWORD")
//go:embed frontend/dist/index.html
//go:embed frontend/dist/assets/*
frontend embed.FS
)
func init() {
flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at")
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size")
flag.StringVar(&configFile, "conf", "", "Config file path")
flag.StringVar(&configFile, "conf", "./config.yml", "Config file path")
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.StringVar(&sessionFilePath, "session", ".", "session file path")
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
flag.BoolVar(&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()
}
@@ -48,17 +63,21 @@ func main() {
c := config.Instance()
c.SetPort(port)
c.QueueSize(queueSize)
c.DownloadPath(downloadPath)
c.DownloaderPath(downloaderPath)
c.Host = host
c.Port = port
c.QueueSize = queueSize
c.DownloadPath = downloadPath
c.DownloaderPath = downloaderPath
c.SessionFilePath = sessionFilePath
c.RequireAuth(requireAuth)
c.RPCSecret(rpcSecret)
c.RequireAuth = requireAuth
c.Username = username
c.Password = password
if configFile != "" {
c.LoadFromFile(configFile)
// if config file is found it will be merged with the current config struct
if err := c.LoadFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, err)
}
server.RunBlocking(port, frontend)
server.RunBlocking(c.Host, c.Port, frontend, localDatabasePath)
}

View File

@@ -7,69 +7,43 @@ import (
"gopkg.in/yaml.v3"
)
var lock sync.Mutex
type serverConfig struct {
Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"`
RequireAuth bool `yaml:"require_auth"`
RPCSecret string `yaml:"rpc_secret"`
QueueSize int `yaml:"queue_size"`
type Config struct {
CurrentLogFile string
LogPath string `yaml:"log_path"`
Host string `yaml:"host"`
Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"`
RequireAuth bool `yaml:"require_auth"`
Username string `yaml:"username"`
Password string `yaml:"password"`
QueueSize int `yaml:"queue_size"`
SessionFilePath string `yaml:"session_file_path"`
}
type config struct {
cfg serverConfig
}
var (
instance *Config
instanceOnce sync.Once
)
func (c *config) LoadFromFile(filename string) (serverConfig, error) {
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
}
func (c *config) QueueSize(size int) {
c.cfg.QueueSize = size
}
var instance *config
func Instance() *config {
func Instance() *Config {
if instance == nil {
lock.Lock()
defer lock.Unlock()
if instance == nil {
instance = &config{serverConfig{}}
}
instanceOnce.Do(func() {
instance = &Config{}
})
}
return instance
}
func (c *Config) LoadFile(filename string) error {
fd, err := os.Open(filename)
if err != nil {
return err
}
if err := yaml.NewDecoder(fd).Decode(c); err != nil {
return err
}
return nil
}

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

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

View File

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

View File

@@ -1,29 +1,36 @@
package handlers
import (
"encoding/json"
"net/http"
"os"
"time"
"github.com/goccy/go-json"
"github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
)
type LoginRequest struct {
Secret string `json:"secret"`
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)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if config.Instance().GetConfig().RPCSecret != req.Secret {
http.Error(w, err.Error(), http.StatusBadRequest)
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
}
@@ -31,6 +38,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"expiresAt": expiresAt,
"username": req.Username,
})
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
@@ -39,21 +47,15 @@ func Login(w http.ResponseWriter, r *http.Request) {
return
}
cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
Expires: expiresAt, // 30 days
Value: tokenString,
Path: "/",
if err := json.NewEncoder(w).Encode(tokenString); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, cookie)
}
func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME,
Name: utils.TOKEN_COOKIE_NAME,
HttpOnly: true,
Secure: false,
Expires: time.Now(),

View File

@@ -7,7 +7,7 @@ type DownloadProgress struct {
Status int `json:"process_status"`
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
ETA int `json:"eta"`
ETA float32 `json:"eta"`
}
// Used to deser the yt-dlp -J output
@@ -50,6 +50,8 @@ type ProcessResponse struct {
Id string `json:"id"`
Progress DownloadProgress `json:"progress"`
Info DownloadInfo `json:"info"`
Output DownloadOutput `json:"output"`
Params []string `json:"params"`
}
// struct representing the current status of the memoryDB
@@ -71,3 +73,14 @@ type DownloadRequest struct {
Rename string `json:"rename"`
Params []string `json:"params"`
}
// 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

@@ -4,12 +4,13 @@ import (
"encoding/gob"
"errors"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
// In-Memory Thread-Safe Key-Value Storage with optional persistence
@@ -28,7 +29,7 @@ func (m *MemoryDB) Get(id string) (*Process, error) {
// Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string {
id := uuid.Must(uuid.NewRandom()).String()
id := uuid.NewString()
m.table.Store(id, process)
process.Id = id
return id
@@ -83,6 +84,8 @@ func (m *MemoryDB) All() *[]ProcessResponse {
Id: key.(string),
Info: value.(*Process).Info,
Progress: value.(*Process).Progress,
Output: value.(*Process).Output,
Params: value.(*Process).Params,
})
return true
})
@@ -90,12 +93,14 @@ func (m *MemoryDB) All() *[]ProcessResponse {
}
// WIP: Persist the database in a single file named "session.dat"
func (m *MemoryDB) Persist() {
func (m *MemoryDB) Persist() error {
running := m.All()
fd, err := os.Create("session.dat")
sf := filepath.Join(config.Instance().SessionFilePath, "session.dat")
fd, err := os.Create(sf)
if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset)
return errors.Join(errors.New("failed to persist session"), err)
}
session := Session{
@@ -104,14 +109,14 @@ func (m *MemoryDB) Persist() {
err = gob.NewEncoder(fd).Encode(session)
if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset)
return errors.Join(errors.New("failed to persist session"), err)
}
log.Println(cli.BgBlue, "Successfully serialized session", cli.Reset)
return nil
}
// WIP: Restore a persisted state
func (m *MemoryDB) Restore() {
func (m *MemoryDB) Restore(logger *slog.Logger) {
fd, err := os.Open("session.dat")
if err != nil {
return
@@ -130,6 +135,9 @@ func (m *MemoryDB) Restore() {
Url: proc.Info.URL,
Info: proc.Info,
Progress: proc.Progress,
Output: proc.Output,
Params: proc.Params,
Logger: logger,
}
m.table.Store(proc.Id, restored)
@@ -138,6 +146,4 @@ func (m *MemoryDB) Restore() {
go restored.Start()
}
}
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)
}

View File

@@ -1,8 +1,6 @@
package internal
import (
"log"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
@@ -16,10 +14,10 @@ type MessageQueue struct {
// CPU cores.
// The queue size can be set via the qs flag.
func NewMessageQueue() *MessageQueue {
size := config.Instance().GetConfig().QueueSize
size := config.Instance().QueueSize
if size <= 0 {
log.Fatalln("invalid queue size")
panic("invalid queue size")
}
return &MessageQueue{

View File

@@ -1,23 +1,28 @@
package internal
import (
"encoding/json"
"errors"
"log"
"log/slog"
"os/exec"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
type metadata struct {
Entries []DownloadInfo `json:"entries"`
Count int `json:"playlist_count"`
Type string `json:"_type"`
Entries []DownloadInfo `json:"entries"`
Count int `json:"playlist_count"`
PlaylistTitle string `json:"title"`
Type string `json:"_type"`
}
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
cmd := exec.Command(cfg.GetConfig().DownloaderPath, req.URL, "-J")
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 {
@@ -31,14 +36,14 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
return err
}
log.Println(cli.BgRed, "Decoding metadata", cli.Reset, req.URL)
logger.Info("decoding metadata", slog.String("url", req.URL))
err = json.NewDecoder(stdout).Decode(&m)
if err != nil {
return err
}
log.Println(cli.BgGreen, "Decoded metadata", cli.Reset, req.URL)
logger.Info("decoded metadata", slog.String("url", req.URL))
if m.Type == "" {
cmd.Wait()
@@ -46,21 +51,36 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
}
if m.Type == "playlist" {
log.Println(
cli.BgGreen, "Playlist detected", cli.Reset, m.Count, "entries",
logger.Info(
"playlist detected",
slog.String("url", req.URL),
slog.Int("count", m.Count),
)
for _, meta := range m.Entries {
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{},
Info: meta,
Params: req.Params,
Output: DownloadOutput{
Filename: req.Rename,
},
Info: meta,
Params: req.Params,
}
proc.Info.URL = meta.OriginalURL
proc.Info.CreatedAt = time.Now().Add(time.Second)
proc.Info.CreatedAt = time.Now().Add(time.Duration(delta))
db.Set(proc)
proc.SetPending()
@@ -71,11 +91,14 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
return err
}
proc := &Process{Url: req.URL, Params: req.Params}
proc := &Process{
Url: req.URL,
Params: req.Params,
Logger: logger,
}
mq.Publish(proc)
log.Println("Sending new process to message queue", proc.Url)
logger.Info("sending new process to message queue", slog.String("url", proc.Url))
err = cmd.Wait()
return err
return cmd.Wait()
}

View File

@@ -2,13 +2,13 @@ package internal
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"sync"
"syscall"
"github.com/goccy/go-json"
"log"
"os"
"os/exec"
@@ -18,6 +18,7 @@ import (
"github.com/marcopeocchi/fazzoletti/slices"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
)
const template = `download:
@@ -27,10 +28,6 @@ const template = `download:
"speed":%(progress.speed)s
}`
var (
cfg = config.Instance()
)
const (
StatusPending = iota
StatusDownloading
@@ -42,7 +39,7 @@ type ProgressTemplate struct {
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
Size string `json:"size"`
Eta int `json:"eta"`
Eta float32 `json:"eta"`
}
// Process descriptor
@@ -54,6 +51,7 @@ type Process struct {
Progress DownloadProgress
Output DownloadOutput
proc *os.Process
Logger *slog.Logger
}
type DownloadOutput struct {
@@ -74,50 +72,72 @@ func (p *Process) Start() {
return !match
})
p.Params = slices.Filter(p.Params, func(e string) bool {
return e != ""
})
out := DownloadOutput{
Path: cfg.GetConfig().DownloadPath,
Path: config.Instance().DownloadPath,
Filename: "%(title)s.%(ext)s",
}
if p.Output.Path != "" {
out.Path = p.Output.Path
}
if p.Output.Filename != "" {
out.Filename = p.Output.Filename + ".%(ext)s"
out.Filename = p.Output.Filename
}
params := append([]string{
buildFilename(&p.Output)
params := []string{
strings.Split(p.Url, "?list")[0], //no playlist
"--newline",
"--no-colors",
"--no-playlist",
"--progress-template", strings.ReplaceAll(template, "\n", ""),
"-o",
fmt.Sprintf("%s/%s", out.Path, out.Filename),
}, p.Params...)
"--progress-template",
strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(template),
}
// if user asked to manually override the output path...
if !(slices.Includes(params, "-P") || slices.Includes(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(cfg.GetConfig().DownloaderPath, params...)
cmd := exec.Command(config.Instance().DownloaderPath, params...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
r, err := cmd.StdoutPipe()
if err != nil {
log.Panicln(err)
p.Logger.Error(
"failed to connect to stdout",
slog.String("err", err.Error()),
)
panic(err)
}
scan := bufio.NewScanner(r)
err = cmd.Start()
if err != nil {
log.Panicln(err)
p.Logger.Error(
"failed to start yt-dlp process",
slog.String("err", err.Error()),
)
panic(err)
}
p.proc = cmd.Process
// ----------------- info block ----------------- //
// spawn a goroutine that retrieves the info for the download
// --------------- progress block --------------- //
// unbuffered channel connected to stdout
var (
sourceChan = make(chan []byte)
doneChan = make(chan struct{})
)
// spawn a goroutine that does the dirty job of parsing the stdout
// filling the channel with as many stdout line as yt-dlp produces (producer)
@@ -125,11 +145,21 @@ func (p *Process) Start() {
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(scan.Bytes(), &stdout)
err := json.Unmarshal(event, &stdout)
if err == nil {
p.Progress = DownloadProgress{
Status: StatusDownloading,
@@ -137,13 +167,13 @@ func (p *Process) Start() {
Speed: stdout.Speed,
ETA: stdout.Eta,
}
log.Println(
cli.BgGreen, "DL", cli.Reset,
cli.BgBlue, p.getShortId(), cli.Reset,
p.Url, stdout.Percentage,
p.Logger.Info("progress",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("percentege", stdout.Percentage),
)
}
}
})
}()
// ------------- end progress block ------------- //
@@ -161,12 +191,9 @@ func (p *Process) Complete() {
ETA: 0,
}
shortId := p.getShortId()
log.Println(
cli.BgMagenta, "FINISH", cli.Reset,
cli.BgBlue, shortId, cli.Reset,
p.Url,
p.Logger.Info("finished",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
)
}
@@ -183,7 +210,7 @@ func (p *Process) Kill() error {
}
err = syscall.Kill(-pgid, syscall.SIGTERM)
log.Println("Killed process", p.Id)
p.Logger.Info("killed process", slog.String("id", p.Id))
return err
}
@@ -192,8 +219,8 @@ func (p *Process) Kill() error {
// 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.StdoutPipe()
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output()
if err != nil {
return DownloadFormats{}, err
@@ -209,29 +236,33 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
wg.Add(2)
err = cmd.Start()
if err != nil {
return DownloadFormats{}, err
}
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, p.getShortId(), 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.NewDecoder(stdout).Decode(&info)
decodingError = json.Unmarshal(stdout, &info)
wg.Done()
}()
go func() {
decodingError = json.NewDecoder(stdout).Decode(&best)
decodingError = json.Unmarshal(stdout, &best)
wg.Done()
}()
wg.Wait()
cmd.Wait()
if decodingError != nil {
return DownloadFormats{}, err
@@ -247,12 +278,16 @@ func (p *Process) SetPending() {
}
func (p *Process) SetMetadata() error {
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.Url, "-J")
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Println("Cannot retrieve info for", p.Url)
p.Logger.Error("failed retrieving info",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", err.Error()),
)
return err
}
@@ -266,10 +301,9 @@ func (p *Process) SetMetadata() error {
return err
}
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, p.getShortId(), cli.Reset,
p.Url,
p.Logger.Info("retrieving metadata",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
)
err = json.NewDecoder(stdout).Decode(&info)
@@ -288,3 +322,16 @@ func (p *Process) SetMetadata() error {
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

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

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,29 @@
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) {
logsChan <- rxgo.Of(string(p))
n = len(p)
err = nil
return
}

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
package rest
import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"github.com/goccy/go-json"
"github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
@@ -54,3 +58,154 @@ func (h *Handler) Running() http.HandlerFunc {
}
}
}
func (h *Handler) SetCookies() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
req := new(internal.SetCookiesRequest)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = h.service.SetCookies(r.Context(), req.Cookies)
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) AddTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
req := new(internal.CustomTemplate)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Name == "" || req.Content == "" {
http.Error(w, "Invalid template", http.StatusBadRequest)
return
}
err = h.service.SaveTemplate(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) GetTemplates() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
templates, err := h.service.GetTemplates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(templates)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) DeleteTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
id := chi.URLParam(r, "id")
err := h.service.DeleteTemplate(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) DirectoryTree() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
tree, err := h.service.DirectoryTree(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(tree)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) DownloadFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
id := chi.URLParam(r, "id")
path, err := h.service.DownloadFile(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add(
"Content-Disposition",
"inline; filename="+filepath.Base(*path),
)
w.Header().Set(
"Content-Type",
"application/octet-stream",
)
fd, err := os.Open(*path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.Copy(w, fd)
}
}

View File

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

View File

@@ -2,14 +2,21 @@ package rest
import (
"context"
"database/sql"
"errors"
"log/slog"
"os"
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
)
type Service struct {
db *internal.MemoryDB
mq *internal.MessageQueue
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
logger *slog.Logger
}
func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
@@ -20,9 +27,10 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
Path: req.Path,
Filename: req.Rename,
},
Logger: s.logger,
}
id := s.db.Set(p)
id := s.mdb.Set(p)
s.mq.Publish(p)
return id, nil
@@ -33,6 +41,94 @@ func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, err
case <-ctx.Done():
return nil, errors.New("context cancelled")
default:
return s.db.All(), nil
return s.mdb.All(), nil
}
}
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) DirectoryTree(ctx context.Context) (*internal.Stack[sys.FSNode], error) {
return sys.DirectoryTree()
}
func (s *Service) DownloadFile(ctx context.Context, id string) (*string, error) {
p, err := s.mdb.Get(id)
if err != nil {
return nil, err
}
return &p.Output.Path, nil
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
package rpc
import (
"log"
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
@@ -9,8 +9,9 @@ import (
)
type Service struct {
db *internal.MemoryDB
mq *internal.MessageQueue
db *internal.MemoryDB
mq *internal.MessageQueue
logger *slog.Logger
}
type Running []internal.ProcessResponse
@@ -34,6 +35,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
Path: args.Path,
Filename: args.Rename,
},
Logger: s.logger,
}
s.db.Set(p)
@@ -46,7 +48,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
// Exec spawns a Process.
// The result of the execution is the newly spawned process Id.
func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error {
err := internal.PlaylistDetect(args, s.mq, s.db)
err := internal.PlaylistDetect(args, s.mq, s.db, s.logger)
if err != nil {
return err
}
@@ -67,10 +69,10 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
}
// Progess retrieves available format for a given resource
func (s *Service) Formats(args Args, progress *internal.DownloadFormats) error {
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
var err error
p := internal.Process{Url: args.URL}
*progress, err = p.GetFormatsSync()
p := internal.Process{Url: args.URL, Logger: s.logger}
*meta, err = p.GetFormatsSync()
return err
}
@@ -88,7 +90,7 @@ func (s *Service) Running(args NoArgs, running *Running) error {
// Kill kills a process given its id and remove it from the memoryDB
func (s *Service) Kill(args string, killed *string) error {
log.Println("Trying killing process with id", args)
s.logger.Info("Trying killing process with id", slog.String("id", args))
proc, err := s.db.Get(args)
if err != nil {
@@ -106,7 +108,7 @@ func (s *Service) Kill(args string, killed *string) error {
// KillAll kills all process unconditionally and removes them from
// the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error {
log.Println("Killing all spawned processes", args)
s.logger.Info("Killing all spawned processes")
keys := s.db.Keys()
var err error
for _, key := range *keys {
@@ -125,7 +127,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
// Remove a process from the db rendering it unusable if active
func (s *Service) Clear(args string, killed *string) error {
log.Println("Clearing process with id", args)
s.logger.Info("Clearing process with id", slog.String("id", args))
s.db.Delete(args)
return nil
}
@@ -138,7 +140,7 @@ func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
}
// Return a flattned tree of the download directory
func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
func (s *Service) DirectoryTree(args NoArgs, tree *internal.Stack[sys.FSNode]) error {
dfsTree, err := sys.DirectoryTree()
if dfsTree != nil {
*tree = *dfsTree
@@ -148,7 +150,7 @@ func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
// Updates the yt-dlp binary using its builtin function
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
log.Println("Updating yt-dlp executable to the latest release")
s.logger.Info("Updating yt-dlp executable to the latest release")
err := updater.UpdateExecutable()
if err != nil {
*updated = true

View File

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

View File

@@ -2,9 +2,11 @@ package server
import (
"context"
"database/sql"
"fmt"
"io"
"io/fs"
"log"
"log/slog"
"net/http"
"net/rpc"
"os"
@@ -13,65 +15,109 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/dbutils"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
_ "modernc.org/sqlite"
)
type serverConfig struct {
frontend fs.FS
logger *slog.Logger
host string
port int
db *internal.MemoryDB
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
}
func RunBlocking(port int, frontend fs.FS) {
var db internal.MemoryDB
db.Restore()
func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
var mdb internal.MemoryDB
logger := slog.New(
slog.NewTextHandler(
io.MultiWriter(os.Stdout, logging.NewObservableLogger()),
nil,
),
)
mdb.Restore(logger)
db, err := sql.Open("sqlite", dbPath)
if err != nil {
logger.Error("failed to open database", slog.String("err", err.Error()))
}
err = dbutils.AutoMigrate(context.Background(), db)
if err != nil {
logger.Error("failed to init database", slog.String("err", err.Error()))
}
mq := internal.NewMessageQueue()
go mq.Subscriber()
srv := newServer(serverConfig{
frontend: frontend,
logger: logger,
host: host,
port: port,
db: &db,
mdb: &mdb,
mq: mq,
db: db,
})
// http-post
go gracefulShutdown(srv, &db)
go autoPersist(time.Minute*5, &db)
go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &mdb, logger)
log.Fatal(srv.ListenAndServe())
logger.Info("yt-dlp-webui started", slog.Int("port", port))
if err := srv.ListenAndServe(); err != nil {
logger.Warn("http server stopped", slog.String("err", err.Error()))
}
}
func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.db, c.mq)
service := ytdlpRPC.Container(c.mdb, c.mq, c.logger)
rpc.Register(service)
r := chi.NewRouter()
r.Use(cors.AllowAll().Handler)
r.Use(middleware.Logger)
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
})
sh := middlewares.NewSpaHandler("index.html", c.frontend)
sh.AddClientRoute("/settings")
sh.AddClientRoute("/archive")
sh.AddClientRoute("/login")
r.Use(corsMiddleware.Handler)
// use in dev
// r.Use(middleware.Logger)
r.Get("/*", sh.Handler())
r.Mount("/", http.FileServer(http.FS(c.frontend)))
// Archive routes
r.Route("/archive", func(r chi.Router) {
r.Use(middlewares.Authenticated)
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Post("/downloaded", handlers.ListDownloaded)
r.Post("/delete", handlers.DeleteFile)
r.Get("/d/{id}", handlers.SendFile)
r.Get("/d/{id}", handlers.DownloadFile)
r.Get("/v/{id}", handlers.SendFile)
})
// Authentication routes
@@ -84,10 +130,13 @@ func newServer(c serverConfig) *http.Server {
r.Route("/rpc", ytdlpRPC.ApplyRouter())
// REST API handlers
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mq))
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mdb, c.mq))
// Logging
r.Route("/log", logging.ApplyRouter())
return &http.Server{
Addr: fmt.Sprintf(":%d", c.port),
Addr: fmt.Sprintf("%s:%d", c.host, c.port),
Handler: r,
}
}
@@ -101,7 +150,7 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
go func() {
<-ctx.Done()
log.Println("shutdown signal received")
slog.Info("shutdown signal received")
defer func() {
db.Persist()
@@ -111,9 +160,15 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
}()
}
func autoPersist(d time.Duration, db *internal.MemoryDB) {
func autoPersist(d time.Duration, db *internal.MemoryDB, logger *slog.Logger) {
for {
db.Persist()
if err := db.Persist(); err != nil {
logger.Info(
"failed to persisted session",
slog.String("err", err.Error()),
)
}
logger.Info("sucessfully persisted session")
time.Sleep(d)
}
}

View File

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

View File

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

5
server/utils/cookie.go Normal file
View File

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

55
server/utils/logrotate.go Normal file
View File

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

24
ui/.gitignore vendored Normal file
View File

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

3
ui/.vscode/extensions.json vendored Normal file
View File

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

47
ui/README.md Normal file
View File

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

16
ui/index.html Normal file
View File

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

31
ui/package.json Normal file
View File

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

1693
ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
ui/postcss.config.js Normal file
View File

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

1
ui/public/svelte.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.9 KiB

1
ui/public/vite.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

25
ui/src/App.svelte Normal file
View File

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

7
ui/src/app.css Normal file
View File

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

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