Compare commits

..

13 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
155 changed files with 9234 additions and 5986 deletions

18
.dockerignore Normal file
View File

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

1
.envrc
View File

@@ -1 +0,0 @@
use flake

2
.gitattributes vendored
View File

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

View File

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

View File

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

12
.gitignore vendored
View File

@@ -1,7 +1,3 @@
.pre-commit-config.yaml
.direnv/
result/
result
dist dist
.pnpm-debug.log .pnpm-debug.log
node_modules node_modules
@@ -18,10 +14,4 @@ session.dat
config.yml config.yml
cookies.txt cookies.txt
__debug* __debug*
ui/ app/
.idea
frontend/.pnp.cjs
frontend/.pnp.loader.mjs
frontend/.yarn/install-state.gz
.db.lock
livestreams.dat

View File

@@ -1,39 +1,27 @@
# Node (pnpm) ------------------------------------------------------------------ FROM golang:alpine AS build
FROM node:20-slim AS ui
ENV PNPM_HOME="/pnpm" RUN apk update && \
ENV PATH="$PNPM_HOME:$PATH" apk add nodejs npm
RUN corepack enable
COPY . /usr/src/yt-dlp-webui COPY . /usr/src/yt-dlp-webui
WORKDIR /usr/src/yt-dlp-webui/frontend WORKDIR /usr/src/yt-dlp-webui/frontend
RUN rm -rf node_modules RUN npm install
RUN npm run build
RUN pnpm install
RUN pnpm run build
# -----------------------------------------------------------------------------
# Go --------------------------------------------------------------------------
FROM golang AS build
WORKDIR /usr/src/yt-dlp-webui WORKDIR /usr/src/yt-dlp-webui
COPY . .
COPY --from=ui /usr/src/yt-dlp-webui/frontend /usr/src/yt-dlp-webui/frontend
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# -----------------------------------------------------------------------------
# dependencies ----------------------------------------------------------------
FROM alpine:edge FROM alpine:edge
RUN apk update && \
apk add ffmpeg yt-dlp ca-certificates curl wget psmisc
VOLUME /downloads /config VOLUME /downloads /config
WORKDIR /app WORKDIR /app
RUN apk update && \
apk add psmisc ffmpeg yt-dlp --no-cache
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret ENV JWT_SECRET=secret

View File

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

142
README.md
View File

@@ -1,8 +1,19 @@
> [!IMPORTANT]
> Major frontend refactoring in progress.
> I won't add features or fix minor issues until completition.
---
# yt-dlp Web UI # yt-dlp Web UI
A not so terrible web ui for yt-dlp. A not so terrible web ui for yt-dlp.
Created for the only purpose of *fetching* videos from my server/nas. Created for the only purpose of *fetching* videos from my server/nas.
Intended to be used with docker and in standalone mode. 😎👍
Developed to be as lightweight as possible (because my server is basically an intel atom sbc).
The bottleneck remains yt-dlp startup time.
**Docker images are available on [Docker Hub](https://hub.docker.com/r/marcobaobao/yt-dlp-webui) or [ghcr.io](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui)**. **Docker images are available on [Docker Hub](https://hub.docker.com/r/marcobaobao/yt-dlp-webui) or [ghcr.io](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui)**.
```sh ```sh
@@ -12,9 +23,43 @@ docker pull marcobaobao/yt-dlp-webui
# latest dev # latest dev
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
``` ```
![output](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/82bcecaf-4ced-441f-9384-105653abfae4)
## Video showcase ![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/a32fbdaa-b033-4aed-b914-a66701ace0ce)
[app.webm](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964) ![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/782c559a-f552-40be-a6fd-10e22f38e85d)
### Integrated File browser
Stream or download your content, easily.
![](https://i.ibb.co/k0qzLds/image.png)
## Changelog
```
05/03/22: Korean translation by kimpig
03/03/22: cut-down image size by switching to Alpine linux based container
01/03/22: Chinese translation by deluxghost
03/02/22: i18n enabled! I need help with the translations :/
27/01/22: Multidownload implemented!
26/01/22: Multiple downloads are being implemented. Maybe by next release they will be there.
Refactoring and JSDoc.
04/01/22: Background jobs now are retrieved!! It's still rudimentary but it leverages on yt-dlp resume feature.
05/05/22: Material UI update.
03/06/22: The most requested feature finally implemented: Format Selection!!
08/06/22: ARM builds.
28/06/22: Reworked resume download feature. Now it's pratically instantaneous. It no longer stops and restarts each process, references to each process are saved in memory.
12/01/23: Switched from TypeScript to Golang on the backend. It was a great effort but it was worth it.
```
## Settings ## Settings
@@ -29,13 +74,22 @@ The currently avaible settings are:
- Pass custom yt-dlp arguments safely - Pass custom yt-dlp arguments safely
- Download queue (limit concurrent downloads) - Download queue (limit concurrent downloads)
![](https://i.ibb.co/YdBVcgc/image.png)
![](https://i.ibb.co/Sf102b1/image.png)
## Format selection ## Format selection
This feature is disabled by default as this intended to be used to retrieve the best quality automatically. This feature is disabled by default as this intended to be used to retrieve the best quality automatically.
To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag! To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag!
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) run ## 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).
- **The download doesn't start.**
- As before server address is not specified or simply yt-dlp process takes a lot of time to fire up. (Forking yt-dlp isn't fast especially if you have a lower-end/low-power NAS/server/desktop where the server is running)
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
```sh ```sh
docker pull marcobaobao/yt-dlp-webui docker pull marcobaobao/yt-dlp-webui
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
@@ -79,20 +133,6 @@ docker run -d \
--qs 2 --qs 2
``` ```
### Docker Compose
```yaml
services:
yt-dlp-webui:
image: marcobaobao/yt-dlp-webui
ports:
- 3033:3033
volumes:
- <your dir>:/downloads # replace <your dir> with a directory on your host system
healthcheck:
test: curl -f http://localhost:3033 || exit 1
restart: unless-stopped
```
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation ## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
```sh ```sh
@@ -125,7 +165,7 @@ Usage yt-dlp-webui:
-port int -port int
Port where server will listen at (default 3033) Port where server will listen at (default 3033)
-qs int -qs int
Download queue size (defaults to the number of logical CPU. A min of 2 is recomended.) Download queue size (default 8)
-user string -user string
Username required for auth Username required for auth
-pass string -pass string
@@ -135,37 +175,21 @@ Usage yt-dlp-webui:
### Config file ### Config file
By running `yt-dlp-webui` in standalone mode you have the ability to also specify a config file. By running `yt-dlp-webui` in standalone mode you have the ability to also specify a config file.
The config file **will overwrite what have been passed as cli argument**. The config file **will overwrite what have been passed as cli argument**.
With Docker, inside the mounted `/conf` volume inside there must be a file named `config.yml`.
```yaml ```yaml
# Simple configuration file for yt-dlp webui # Simple configuration file for yt-dlp webui
--- ---
# Host where server will listen at (default: "0.0.0.0")
#host: 0.0.0.0
# Port where server will listen at (default: 3033)
port: 8989 port: 8989
# Directory where downloaded files will be stored (default: ".")
downloadPath: /home/ren/archive downloadPath: /home/ren/archive
downloaderPath: /usr/local/bin/yt-dlp
# [optional] Enable RPC authentication (requires username and password) # Optional settings
require_auth: true require_auth: true
username: my_username username: my_username
password: my_random_secret password: my_random_secret
# [optional] The download queue size (default: 8)
queue_size: 4 queue_size: 4
# [optional] Full path to the yt-dlp (default: "yt-dlp")
downloaderPath: /usr/local/bin/yt-dlp
# [optional] Directory where the log file will be stored (default: ".")
#log_path: .
# [optional] Directory where the session database file will be stored (default: ".")
#session_file_path: .
``` ```
### Systemd integration ### Systemd integration
@@ -188,29 +212,6 @@ WantedBy=multi-user.target
systemctl enable yt-dlp-webui systemctl enable yt-dlp-webui
systemctl start yt-dlp-webui systemctl start yt-dlp-webui
``` ```
It could be that yt-dlp-webui works correctly when started manually from the console, but with systemd, it does not see the yt-dlp executable, or has issues writing to the database file. One way to fix these issues could be as follows:
```shell
cd
mkdir yt-dlp-webui-workingdir
# optionally move the already existing database file there:
mv local.db yt-dlp-webui-workingdir
nano yt-dlp-webui-workingdir/my.conf
```
The config file format is described above; make sure to include the `downloaderPath` setting (the path can possibly be found by running `which yt-dlp`). For example, one could have:
```
downloadPath: /stuff/media
downloaderPath: /home/your_user/.local/bin/yt-dlp
log_path: /home/your_user/yt-dlp-webui-workingdir
session_file_path: /home/your_user/yt-dlp-webui-workingdir
```
Adjust the Service section in the `/etc/systemd/system/yt-dlp-webui.service` file as follows:
```
[Service]
User=your_user
Group=your_user
WorkingDirectory=/home/your_user/yt-dlp-webui-workingdir
ExecStart=/usr/local/bin/yt-dlp-webui --conf /home/your_user/yt-dlp-webui-workingdir/my.conf
```
## Manual installation ## Manual installation
```sh ```sh
@@ -222,9 +223,6 @@ npm run build
go build -o yt-dlp-webui main.go go build -o yt-dlp-webui main.go
``` ```
## Open-API
Navigate to `/openapi` to see the related swagger.
## Extendable ## Extendable
You dont'like the Material feel? You dont'like the Material feel?
@@ -233,17 +231,17 @@ Want to build your own frontend? We got you covered 🤠
`yt-dlp-webui` now exposes a nice **JSON-RPC 1.0** interface through Websockets and HTTP-POST `yt-dlp-webui` now exposes a nice **JSON-RPC 1.0** interface through Websockets and HTTP-POST
It is **planned** to also expose a **gRPC** server. It is **planned** to also expose a **gRPC** server.
For more information open an issue on GitHub and I will provide more info ASAP. Just as an overview, these are the available methods:
- Service.Exec
- Service.Progress
- Service.Formats
- Service.Pending
- Service.Running
- Service.Kill
- Service.KillAll
- Service.Clear
## Nix For more information open an issue on GitHub and I will provide more info ASAP.
This repo adds support for Nix(OS) in various ways through a `flake-parts` flake.
For more info, please refer to the [official documentation](https://nixos.org/learn/).
## What yt-dlp-webui is not ## What yt-dlp-webui is not
`yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS. `yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS.
## 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).
- **The download doesn't start.**
- As before server address is not specified or simply yt-dlp process takes a lot of time to fire up. (Forking yt-dlp isn't fast especially if you have a lower-end/low-power NAS/server/desktop where the server is running)

View File

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

149
flake.lock generated
View File

@@ -1,149 +0,0 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1722555600,
"narHash": "sha256-XOQkdLafnb/p9ij77byFQjDf5m5QYl9b2REiVClC+x4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "8471fe90ad337a8074e957b69ca4d0089218391d",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1723637854,
"narHash": "sha256-med8+5DSWa2UnOqtdICndjDAEjxr5D7zaIiK4pn0Q7c=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c3aa7b8938b17aebd2deecf7be0636000d62a2b9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1722555339,
"narHash": "sha256-uFf2QeW7eAHlYXuDktm9c25OxOyCoUOQmh5SZ9amE5Q=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/a5d394176e64ab29c852d03346c1fc9b0b7d33eb.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/a5d394176e64ab29c852d03346c1fc9b0b7d33eb.tar.gz"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1720386169,
"narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "194846768975b7ad2c4988bdb82572c00222c0d7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1719082008,
"narHash": "sha256-jHJSUH619zBQ6WdC21fFAlDxHErKVDJ5fpN0Hgx4sjs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9693852a2070b398ee123a329e68f0dab5526681",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks-nix": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs_2",
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1723803910,
"narHash": "sha256-yezvUuFiEnCFbGuwj/bQcqg7RykIEqudOy/RBrId0pc=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "bfef0ada09e2c8ac55bbcd0831bd0c9d42e651ba",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"pre-commit-hooks-nix": "pre-commit-hooks-nix"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,51 +0,0 @@
{
description = "A terrible web ui for yt-dlp. Designed to be self-hosted.";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
pre-commit-hooks-nix.url = "github:cachix/pre-commit-hooks.nix";
};
outputs = inputs@{ self, flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.pre-commit-hooks-nix.flakeModule
];
systems = [
"x86_64-linux"
];
perSystem = { config, self', pkgs, ... }: {
packages = {
yt-dlp-web-ui-frontend = pkgs.callPackage ./nix/frontend.nix { };
default = pkgs.callPackage ./nix/server.nix {
inherit (self'.packages) yt-dlp-web-ui-frontend;
};
};
checks = import ./nix/tests { inherit self pkgs; };
pre-commit = {
check.enable = true;
settings = {
hooks = {
${self'.formatter.pname}.enable = true;
deadnix.enable = true;
nil.enable = true;
statix.enable = true;
};
};
};
devShells.default = pkgs.callPackage ./nix/devShell.nix {
inputsFrom = [ config.pre-commit.devShell ];
};
formatter = pkgs.nixpkgs-fmt;
};
flake = {
nixosModules.default = import ./nix/module.nix self.packages;
};
};
}

View File

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

3249
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

2613
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,11 +1,10 @@
import { ThemeProvider } from '@emotion/react' import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard' import Dashboard from '@mui/icons-material/Dashboard'
import LiveTvIcon from '@mui/icons-material/LiveTv' import DownloadIcon from '@mui/icons-material/Download'
import Menu from '@mui/icons-material/Menu' import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings' import SettingsIcon from '@mui/icons-material/Settings'
import TerminalIcon from '@mui/icons-material/Terminal' import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import { Box, createTheme } from '@mui/material' import { Box, createTheme } from '@mui/material'
import CssBaseline from '@mui/material/CssBaseline' import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
@@ -17,23 +16,26 @@ import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors' import { grey } from '@mui/material/colors'
import { useMemo, useState } from 'react' import { Suspense, useMemo, useState } from 'react'
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet } from 'react-router-dom'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { settingsState } from './atoms/settings' import { settingsState } from './atoms/settings'
import { connectedState } from './atoms/status'
import AppBar from './components/AppBar' import AppBar from './components/AppBar'
import Drawer from './components/Drawer' import Drawer from './components/Drawer'
import Footer from './components/Footer' import FreeSpaceIndicator from './components/FreeSpaceIndicator'
import Logout from './components/Logout' import Logout from './components/Logout'
import SocketSubscriber from './components/SocketSubscriber' import SocketSubscriber from './components/SocketSubscriber'
import ThemeToggler from './components/ThemeToggler' import ThemeToggler from './components/ThemeToggler'
import { useI18n } from './hooks/useI18n' import { useI18n } from './hooks/useI18n'
import Toaster from './providers/ToasterProvider' import Toaster from './providers/ToasterProvider'
import TerminalIcon from '@mui/icons-material/Terminal'
export default function Layout() { export default function Layout() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const settings = useRecoilValue(settingsState) const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState)
const mode = settings.theme const mode = settings.theme
const theme = useMemo(() => const theme = useMemo(() =>
@@ -79,6 +81,21 @@ export default function Layout() {
> >
{settings.appTitle} {settings.appTitle}
</Typography> </Typography>
<Suspense fallback={i18n.t('loadingLabel')}>
<FreeSpaceIndicator />
</Suspense>
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
marginLeft: '4px',
gap: 3,
}}>
<SettingsEthernet />
<span>
{isConnected ? settings.serverAddr : i18n.t('notConnectedText')}
</span>
</div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer variant="permanent" open={open}> <Drawer variant="permanent" open={open}>
@@ -117,20 +134,7 @@ export default function Layout() {
}> }>
<ListItemButton> <ListItemButton>
<ListItemIcon> <ListItemIcon>
<ArchiveIcon /> <DownloadIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/monitor'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<LiveTvIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} /> <ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton> </ListItemButton>
@@ -177,7 +181,6 @@ export default function Layout() {
<Outlet /> <Outlet />
</Box> </Box>
</Box> </Box>
<Footer />
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>
) )

View File

@@ -1,7 +1,7 @@
--- ---
languages: languages:
english: english:
urlInput: Video URL (one per line) urlInput: Video URL
statusTitle: Status statusTitle: Status
statusReady: Ready statusReady: Ready
selectFormatButton: Select format selectFormatButton: Select format
@@ -24,9 +24,9 @@ languages:
overridesAnchor: Overrides overridesAnchor: Overrides
pathOverrideOption: Enable output path overriding pathOverrideOption: Enable output path overriding
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: Enable output file name overriding
customFilename: Custom filename (leave blank to use default) customFilename: Custom filemame (leave blank to use default)
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsibilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server rpcConnErr: Error while conencting to RPC server
splashText: No active downloads splashText: No active downloads
@@ -34,8 +34,7 @@ languages:
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
restartAppMessage: Needs a page reload to take effect restartAppMessage: Needs a page reload to take effect
servedFromReverseProxyCheckbox: Is behind a reverse proxy servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
newDownloadButton: New download newDownloadButton: New download
homeButtonLabel: Home homeButtonLabel: Home
archiveButtonLabel: Archive archiveButtonLabel: Archive
@@ -50,21 +49,6 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
rpcPollingTimeTitle: RPC polling time
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
templatesReloadInfo: To register a new template it might need a page reload.
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
german: german:
urlInput: Video URL urlInput: Video URL
statusTitle: Status statusTitle: Status
@@ -89,7 +73,7 @@ languages:
overridesAnchor: Überschreibungen overridesAnchor: Überschreibungen
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
customFilename: Custom filename (leave blank to use default) customFilename: Custom filemame (leave blank to use default)
customPath: Benutzerdefinierter Pfad customPath: Benutzerdefinierter Pfad
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung) customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
customArgsInput: Benutzerdefinierte yt-dlp Argumente customArgsInput: Benutzerdefinierte yt-dlp Argumente
@@ -114,18 +98,6 @@ languages:
templatesEditorContentLabel: Vorlagen Inhalt templatesEditorContentLabel: Vorlagen Inhalt
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
french: french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut statusTitle: Statut
@@ -177,20 +149,8 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
italian: italian:
urlInput: URL Video (uno per linea) urlInput: URL Video
statusTitle: Stato statusTitle: Stato
startButton: Inizia startButton: Inizia
statusReady: Pronto statusReady: Pronto
@@ -212,7 +172,7 @@ languages:
overridesAnchor: Sovrascritture overridesAnchor: Sovrascritture
pathOverrideOption: Abilita sovrascrittura percorso di output pathOverrideOption: Abilita sovrascrittura percorso di output
filenameOverrideOption: Abilita sovrascrittura del nome del file di output filenameOverrideOption: Abilita sovrascrittura del nome del file di output
customFilename: Custom filename (leave blank to use default) customFilename: Custom filemame (leave blank to use default)
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
@@ -237,18 +197,6 @@ languages:
templatesEditorContentLabel: Contentunto template templatesEditorContentLabel: Contentunto template
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
chinese: chinese:
urlInput: 视频 URL urlInput: 视频 URL
statusTitle: 状态 statusTitle: 状态
@@ -298,18 +246,6 @@ languages:
templatesEditorContentLabel: 模板内容 templatesEditorContentLabel: 模板内容
logsTitle: '日志' logsTitle: '日志'
awaitingLogs: '正在等待日志…' awaitingLogs: '正在等待日志…'
bulkDownload: '下载 zip 压缩包中的文件'
livestreamURLInput: 直播 URL
livestreamStatusWaiting: 等待直播开始
livestreamStatusDownloading: 下载中
livestreamStatusCompleted: 已完成
livestreamStatusErrored: 发生错误
livestreamStatusUnknown: 未知
livestreamDownloadInfo: |
本功能将会监控即将开始的直播流,每个进程都会传入参数:--wait-for-video 10 重试间隔10秒
如果直播已经开始,那么依然可以下载,但是不会记录下载进度。
直播开始后,将会转移到下载页面
livestreamExperimentalWarning: 实验性功能可能存在未知Bug请谨慎使用
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -357,18 +293,6 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
russian: russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус statusTitle: Статус
@@ -400,34 +324,22 @@ languages:
splashText: Нет активных загрузок splashText: Нет активных загрузок
archiveTitle: Архив archiveTitle: Архив
clipboardAction: URL скопирован в буфер обмена clipboardAction: URL скопирован в буфер обмена
playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Находится за обратным прокси servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: Новая загрузка newDownloadButton: New download
homeButtonLabel: Home homeButtonLabel: Home
archiveButtonLabel: Архив archiveButtonLabel: Archive
settingsButtonLabel: Настройки settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC-аутентификация rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Переключить тему themeTogglerLabel: Theme toggler
loadingLabel: Загрузка... loadingLabel: Loading...
appTitle: Название приложения appTitle: App title
savedTemplates: Сохраненные шаблоны savedTemplates: Saved templates
templatesEditor: Редактор шаблонов templatesEditor: Templates editor
templatesEditorNameLabel: Имя шаблона templatesEditorNameLabel: Template name
templatesEditorContentLabel: Содержание шаблона templatesEditorContentLabel: Template content
logsTitle: 'Логи' logsTitle: 'Logs'
awaitingLogs: 'Ожидание логов...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Скачать файлы в zip архиве'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -451,7 +363,7 @@ languages:
overridesAnchor: Overrides overridesAnchor: Overrides
pathOverrideOption: Enable output path overriding pathOverrideOption: Enable output path overriding
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: Enable output file name overriding
customFilename: Custom filename (leave blank to use default) customFilename: Custom filemame (leave blank to use default)
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
@@ -475,18 +387,6 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -535,18 +435,6 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
catalan: catalan:
urlInput: URL de YouTube o d'un altre servei compatible urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat statusTitle: Estat
@@ -594,18 +482,6 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
ukrainian: ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус statusTitle: Статус
@@ -653,18 +529,6 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
polish: polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status statusTitle: Status
@@ -712,80 +576,3 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
swedish:
urlInput: Videolänk (en per rad)
statusTitle: Status
statusReady: Redo
selectFormatButton: Välj format
startButton: Start
abortAllButton: Avbryt alla
updateBinButton: Uppdatera yt-dlp
darkThemeButton: Mörkt tema
lightThemeButton: Ljust tema
settingsAnchor: Inställningar
serverAddressTitle: Serveraddress
serverPortTitle: Port
extractAudioCheckbox: Extrahera ljud
noMTimeCheckbox: Lägg inte till info om när filen senast modifierades
bgReminder: När du stänger denna sida så kommer nedladdningen att fortsätta i bakgrunden.
toastConnected: 'Ansluten till '
toastUpdated: Uppdaterade yt-dlp!
formatSelectionEnabler: Tillåt val av ljud- och bildformat
themeSelect: 'Tema'
languageSelect: 'Språk'
overridesAnchor: Överskrivningar
pathOverrideOption: Tillåt överskrivning av filsökvägen
filenameOverrideOption: Tillåt överskrivning av filnamn
customFilename: Eget filnamn (lämna blankt för standardnamn)
customPath: Egen filsökväg
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
customArgsInput: Egna yt-dlp-argument
rpcConnErr: Ett fel inträffade vid anslutning till RPC-server
splashText: Inga pågående nedladdningar
archiveTitle: Arkiv
clipboardAction: Kopierade länken
playlistCheckbox: Ladda ner spellista (detta kommer ta did, efter start så kan du stänga detta fönster)
restartAppMessage: En sidomladdning behövs innan förändringen får effekt
servedFromReverseProxyCheckbox: Servern befinner sig bakom en omvänd proxy
urlBase: "URL-bas, måste anges när en omvänd proxy används. Standardinställning: lämna blank"
newDownloadButton: Ny nedladdning
homeButtonLabel: Hem
archiveButtonLabel: Arkiv
settingsButtonLabel: Inställningar
rpcAuthenticationLabel: RPC-Autentisering
themeTogglerLabel: Tema-knapp
loadingLabel: Laddar...
appTitle: Apptitel
savedTemplates: Sparade mallar
templatesEditor: Mallredigerare
templatesEditorNameLabel: Namn
templatesEditorContentLabel: Innehåll
logsTitle: 'Loggar'
awaitingLogs: 'Väntar på loggar...'
bulkDownload: 'Ladda ner filer i ett zip-arkiv'
rpcPollingTimeTitle: Frekvens av RPC-uppdateringar
rpcPollingTimeDescription: En högre frekvens kräver mer CPU-resurser för både server och klient
templatesReloadInfo: För att registrera en ny mall så kan en sidomladdning krävas.
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!

View File

@@ -1,15 +1,16 @@
import { getOrElse } from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/function'
import { atom, selector } from 'recoil' import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient'
import { CustomTemplate } from '../types' import { CustomTemplate } from '../types'
import { serverSideCookiesState, serverURL } from './settings' import { ffetch } from '../lib/httpClient'
import { serverURL } from './settings'
import { pipe } from 'fp-ts/lib/function'
import { getOrElse } from 'fp-ts/lib/Either'
export const cookiesTemplateState = selector({ export const cookiesTemplateState = atom({
key: 'cookiesTemplateState', key: 'cookiesTemplateState',
get: ({ get }) => get(serverSideCookiesState) default: localStorage.getItem('cookiesTemplate') ?? '',
? '--cookies=cookies.txt' effects: [
: '' ({ onSet }) => onSet(e => localStorage.setItem('cookiesTemplate', e))
]
}) })
export const customArgsState = atom({ export const customArgsState = atom({

View File

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

View File

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

View File

@@ -1,7 +1,4 @@
import { pipe } from 'fp-ts/lib/function'
import { matchW } from 'fp-ts/lib/TaskEither'
import { atom, selector } from 'recoil' import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient'
import { prefersDarkMode } from '../utils' import { prefersDarkMode } from '../utils'
export const languages = [ export const languages = [
@@ -67,7 +64,8 @@ export const serverAddressState = atom<string>({
export const serverPortState = atom<number>({ export const serverPortState = atom<number>({
key: 'serverPortState', key: 'serverPortState',
default: Number(localStorage.getItem('server-port')) || Number(window.location.port), default: Number(localStorage.getItem('server-port')) ||
Number(window.location.port),
effects: [ effects: [
({ onSet }) => ({ onSet }) =>
onSet(a => localStorage.setItem('server-port', a.toString())) onSet(a => localStorage.setItem('server-port', a.toString()))
@@ -137,15 +135,6 @@ export const servedFromReverseProxyState = atom({
] ]
}) })
export const servedFromReverseProxySubDirState = atom<string>({
key: 'servedFromReverseProxySubDirState',
default: localStorage.getItem('reverseProxySubDir') ?? '',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxySubDir', a))
]
})
export const appTitleState = atom({ export const appTitleState = atom({
key: 'appTitleState', key: 'appTitleState',
default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI', default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI',
@@ -157,15 +146,9 @@ export const appTitleState = atom({
export const serverAddressAndPortState = selector({ export const serverAddressAndPortState = selector({
key: 'serverAddressAndPortState', key: 'serverAddressAndPortState',
get: ({ get }) => { get: ({ get }) => get(servedFromReverseProxyState)
if (get(servedFromReverseProxySubDirState)) { ? `${get(serverAddressState)}`
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/` : `${get(serverAddressState)}:${get(serverPortState)}`
}
if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}`
}
return `${get(serverAddressState)}:${get(serverPortState)}`
}
}) })
export const serverURL = selector({ export const serverURL = selector({
@@ -190,18 +173,16 @@ export const rpcHTTPEndpoint = selector({
} }
}) })
export const serverSideCookiesState = selector<string>({ export const cookiesState = atom({
key: 'serverSideCookiesState', key: 'cookiesState',
get: async ({ get }) => await pipe( default: localStorage.getItem('yt-dlp-cookies') ?? '',
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`), effects: [
matchW( ({ onSet }) =>
() => '', onSet(c => localStorage.setItem('yt-dlp-cookies', c))
(r) => r.cookies ]
)
)()
}) })
const themeSelector = selector<ThemeNarrowed>({ export const themeSelector = selector<ThemeNarrowed>({
key: 'themeSelector', key: 'themeSelector',
get: ({ get }) => { get: ({ get }) => {
const theme = get(themeState) const theme = get(themeState)

View File

@@ -1,16 +1,28 @@
import { pipe } from 'fp-ts/lib/function'
import { of } from 'fp-ts/lib/Task'
import { getOrElse } from 'fp-ts/lib/TaskEither'
import { atom, selector } from 'recoil' import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient'
import { rpcClientState } from './rpc' import { rpcClientState } from './rpc'
import { serverURL } from './settings'
type StatusState = {
connected: boolean,
updated: boolean,
downloading: boolean,
}
export const connectedState = atom({ export const connectedState = atom({
key: 'connectedState', key: 'connectedState',
default: false default: false
}) })
export const updatedBinaryState = atom({
key: 'updatedBinaryState',
default: false
})
export const isDownloadingState = atom({
key: 'isDownloadingState',
default: false
})
export const freeSpaceBytesState = selector({ export const freeSpaceBytesState = selector({
key: 'freeSpaceBytesState', key: 'freeSpaceBytesState',
get: async ({ get }) => { get: async ({ get }) => {
@@ -28,14 +40,3 @@ export const availableDownloadPathsState = selector({
return res.result return res.result
} }
}) })
export const ytdlpVersionState = selector<string>({
key: 'ytdlpVersionState',
get: async ({ get }) => await pipe(
ffetch<string>(`${get(serverURL)}/api/v1/version`),
getOrElse(() => pipe(
'unknown version',
of
)),
)()
})

View File

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

View File

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

View File

@@ -1,20 +1,22 @@
import { Button, TextField } from '@mui/material' import { TextField } from '@mui/material'
import * as A from 'fp-ts/Array' import * as A from 'fp-ts/Array'
import * as E from 'fp-ts/Either' import * as E from 'fp-ts/Either'
import * as O from 'fp-ts/Option' import * as O from 'fp-ts/Option'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs' import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { serverSideCookiesState, serverURL } from '../atoms/settings' import { cookiesTemplateState } from '../atoms/downloadTemplate'
import { cookiesState, serverURL } from '../atoms/settings'
import { useSubscription } from '../hooks/observable' import { useSubscription } from '../hooks/observable'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
const validateCookie = (cookie: string) => pipe( const validateCookie = (cookie: string) => pipe(
cookie, cookie,
cookie => cookie.split('\t'), cookie => cookie.replace(/\s\s+/g, ' '),
cookie => cookie.replaceAll('\t', ' '),
cookie => cookie.split(' '),
E.of, E.of,
E.flatMap( E.flatMap(
E.fromPredicate( E.fromPredicate(
@@ -66,19 +68,13 @@ const validateCookie = (cookie: string) => pipe(
), ),
) )
const noopValidator = (s: string): E.Either<string, string[]> => pipe(
s,
s => s.split('\t'),
E.of
)
const isCommentOrNewLine = (s: string) => s === '' || s.startsWith('\n') || s.startsWith('#')
const CookiesTextField: React.FC = () => { const CookiesTextField: React.FC = () => {
const serverAddr = useRecoilValue(serverURL) const serverAddr = useRecoilValue(serverURL)
const savedCookies = useRecoilValue(serverSideCookiesState) const [, setCookies] = useRecoilState(cookiesTemplateState)
const [savedCookies, setSavedCookies] = useRecoilState(cookiesState)
const { pushMessage } = useToast() const { pushMessage } = useToast()
const flag = '--cookies=cookies.txt'
const cookies$ = useMemo(() => new Subject<string>(), []) const cookies$ = useMemo(() => new Subject<string>(), [])
@@ -90,41 +86,28 @@ const CookiesTextField: React.FC = () => {
}) })
})() })()
const deleteCookies = () => pipe(
ffetch(`${serverAddr}/api/v1/cookies`, {
method: 'DELETE',
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => {
pushMessage('Deleted cookies', 'success')
pushMessage(`Reload the page to apply the changes`, 'info')
}
)
)()
const validateNetscapeCookies = (cookies: string) => pipe( const validateNetscapeCookies = (cookies: string) => pipe(
cookies, cookies,
cookies => cookies.split('\n'), cookies => cookies.split('\n'),
A.map(c => isCommentOrNewLine(c) ? noopValidator(c) : validateCookie(c)), // validate line cookies => cookies.filter(f => !f.startsWith('\n')), // empty lines
A.mapWithIndex((i, either) => pipe( // detect errors and return the either cookies => cookies.filter(f => !f.startsWith('# ')), // comments
cookies => cookies.filter(Boolean), // empty lines
A.map(validateCookie),
A.mapWithIndex((i, either) => pipe(
either, either,
E.match( E.matchW(
(l) => { (l) => pushMessage(`Error in line ${i + 1}: ${l}`, 'warning'),
pushMessage(`Error in line ${i + 1}: ${l}`, 'warning') () => E.isRight(either)
return either
},
(_) => either
), ),
)), )),
A.filter(c => E.isRight(c)), // filter the line who didn't pass the validation A.filter(Boolean),
A.map(E.getOrElse(() => new Array<string>())), // cast the array of eithers to an array of tokens A.match(
A.filter(f => f.length > 0), // filter the empty tokens () => false,
A.map(f => f.join('\t')), // join the tokens in a TAB separated string (c) => {
A.reduce('', (c, n) => `${c}${n}\n`), // reduce all to a single string separated by \n pushMessage(`Valid ${c.length} Netscape cookies`, 'info')
parsed => parsed.length > 0 // if nothing has passed the validation return none return true
? O.some(parsed) }
: O.none )
) )
useSubscription( useSubscription(
@@ -134,17 +117,22 @@ const CookiesTextField: React.FC = () => {
), ),
(cookies) => pipe( (cookies) => pipe(
cookies, cookies,
cookies => {
setSavedCookies(cookies)
return cookies
},
validateNetscapeCookies, validateNetscapeCookies,
O.fromPredicate(f => f === true),
O.match( O.match(
() => pushMessage('No valid cookies', 'warning'), () => setCookies(''),
async (some) => { async () => {
pipe( pipe(
await submitCookies(some.trimEnd()), await submitCookies(cookies),
E.match( E.match(
(l) => pushMessage(`${l}`, 'error'), (l) => pushMessage(`${l}`, 'error'),
() => { () => {
pushMessage(`Saved ${some.split('\n').length} Netscape cookies`, 'success') pushMessage(`Saved Netscape cookies`, 'success')
pushMessage('Reload the page to apply the changes', 'info') setCookies(flag)
} }
) )
) )
@@ -154,7 +142,6 @@ const CookiesTextField: React.FC = () => {
) )
return ( return (
<>
<TextField <TextField
label="Netscape Cookies" label="Netscape Cookies"
multiline multiline
@@ -164,8 +151,6 @@ const CookiesTextField: React.FC = () => {
defaultValue={savedCookies} defaultValue={savedCookies}
onChange={(e) => cookies$.next(e.currentTarget.value)} onChange={(e) => cookies$.next(e.currentTarget.value)}
/> />
<Button onClick={deleteCookies}>Delete cookies</Button>
</>
) )
} }

View File

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

View File

@@ -26,19 +26,21 @@ import {
Suspense, Suspense,
forwardRef, forwardRef,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
useTransition useTransition
} from 'react' } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate' import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate'
import { settingsState } from '../atoms/settings' import { latestCliArgumentsState, settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status' import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser'
import type { DLMetadata } from '../types' import type { DLMetadata } from '../types'
import { toFormatArgs } from '../utils' import { isValidURL, toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions' import ExtraDownloadOptions from './ExtraDownloadOptions'
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
@@ -69,6 +71,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const [pickedBestFormat, setPickedBestFormat] = useState('') const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useRecoilState(customArgsState) const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [downloadPath, setDownloadPath] = useState('') const [downloadPath, setDownloadPath] = useState('')
@@ -77,9 +80,14 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
) )
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [workingUrl, setWorkingUrl] = useState('')
const [isPlaylist, setIsPlaylist] = useState(false) const [isPlaylist, setIsPlaylist] = useState(false)
const argsBuilder = useMemo(() =>
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
@@ -95,36 +103,36 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
/** /**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket * Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
*/ */
const sendUrl = async (immediate?: string) => { const sendUrl = (immediate?: string) => {
for (const line of url.split('\n')) {
const codes = new Array<string>() const codes = new Array<string>()
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat) if (pickedVideoFormat !== '') codes.push(pickedVideoFormat)
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat) if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat) if (pickedBestFormat !== '') codes.push(pickedBestFormat)
await new Promise(r => setTimeout(r, 10))
client.download({ client.download({
url: immediate || line, url: immediate || url || workingUrl,
args: `${toFormatArgs(codes)} ${downloadTemplate}`, args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
pathOverride: downloadPath ?? '', pathOverride: downloadPath ?? '',
renameTo: settings.fileRenaming ? filenameTemplate : '', renameTo: settings.fileRenaming ? filenameTemplate : '',
playlist: isPlaylist, playlist: isPlaylist,
}) })
setUrl('')
setWorkingUrl('')
setTimeout(() => { setTimeout(() => {
resetInput() resetInput()
setDownloadFormats(undefined) setDownloadFormats(undefined)
onDownloadStart(immediate || line) onDownloadStart(immediate || url || workingUrl)
}, 100) }, 250)
}
setUrl('')
} }
/** /**
* Retrive url from input and display the formats selection view * Retrive url from input and display the formats selection view
*/ */
const sendUrlFormatSelection = () => { const sendUrlFormatSelection = () => {
setWorkingUrl(url)
setUrl('')
setPickedAudioFormat('') setPickedAudioFormat('')
setPickedVideoFormat('') setPickedVideoFormat('')
setPickedBestFormat('') setPickedBestFormat('')
@@ -158,6 +166,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
file file
.split('\n') .split('\n')
.filter(u => isValidURL(u))
.forEach(u => sendUrl(u)) .forEach(u => sendUrl(u))
} }
@@ -211,7 +220,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
> >
<Grid container> <Grid container>
<TextField <TextField
multiline
fullWidth fullWidth
ref={urlInputRef} ref={urlInputRef}
label={i18n.t('urlInput')} label={i18n.t('urlInput')}
@@ -319,6 +327,19 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
label={i18n.t('playlistCheckbox')} label={i18n.t('playlistCheckbox')}
/> />
</Grid> </Grid>
<Grid item>
<FormControlLabel
control={
<Checkbox
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
/>
}
checked={argsBuilder.extractAudio}
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
disabled={settings.formatSelection}
label={i18n.t('extractAudioCheckbox')}
/>
</Grid>
</Grid> </Grid>
<Grid item> <Grid item>
<Button <Button

View File

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

View File

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

View File

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

View File

@@ -1,216 +0,0 @@
import DeleteIcon from '@mui/icons-material/Delete'
import DownloadIcon from '@mui/icons-material/Download'
import DownloadDoneIcon from '@mui/icons-material/DownloadDone'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
import SmartDisplayIcon from '@mui/icons-material/SmartDisplay'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import {
Box,
ButtonGroup,
IconButton,
LinearProgress,
LinearProgressProps,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from "@mui/material"
import { forwardRef } from 'react'
import { TableComponents, TableVirtuoso } from 'react-virtuoso'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types'
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
const columns = [
{
width: 8,
label: 'Status',
dataKey: 'status',
numeric: false,
},
{
width: 500,
label: 'Title',
dataKey: 'title',
numeric: false,
},
{
width: 50,
label: 'Speed',
dataKey: 'speed',
numeric: true,
},
{
width: 150,
label: 'Progress',
dataKey: 'progress',
numeric: true,
},
{
width: 80,
label: 'Size',
dataKey: 'size',
numeric: true,
},
{
width: 100,
label: 'Added on',
dataKey: 'addedon',
numeric: true,
},
{
width: 80,
label: 'Actions',
dataKey: 'actions',
numeric: true,
},
] as const
function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress variant="determinate" {...props} />
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography variant="body2" color="text.secondary">{`${Math.round(
props.value,
)}%`}</Typography>
</Box>
</Box>
)
}
const VirtuosoTableComponents: TableComponents<RPCResult> = {
Scroller: forwardRef<HTMLDivElement>((props, ref) => (
<TableContainer {...props} ref={ref} />
)),
Table: (props) => (
<Table {...props} sx={{ borderCollapse: 'separate', tableLayout: 'fixed', mt: 2 }} size='small' />
),
TableHead,
TableRow: ({ item: _item, ...props }) => <TableRow {...props} />,
TableBody: forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableBody {...props} ref={ref} />
)),
}
function fixedHeaderContent() {
return (
<TableRow>
{columns.map((column) => (
<TableCell
key={column.dataKey}
variant="head"
align={column.numeric || false ? 'right' : 'left'}
style={{ width: column.width }}
>
{column.label}
</TableCell>
))}
</TableRow>
)
}
const DownloadsTableView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState)
const serverAddr = useRecoilValue(serverURL)
const { client } = useRPC()
const viewFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
}
const downloadFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
}
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
? client.clear(r.id)
: client.kill(r.id)
function rowContent(_index: number, download: RPCResult) {
return (
<>
<TableCell>
{download.progress.percentage === '-1'
? <DownloadDoneIcon color="primary" />
: <DownloadIcon color="primary" />
}
</TableCell>
<TableCell>{download.info.title}</TableCell>
<TableCell align="right">{formatSpeedMiB(download.progress.speed)}</TableCell>
<TableCell align="right">
<LinearProgressWithLabel
sx={{ height: '16px' }}
value={
download.progress.percentage === '-1'
? 100
: Number(download.progress.percentage.replace('%', ''))
}
variant={
download.progress.process_status === 0
? 'indeterminate'
: 'determinate'
}
color={download.progress.percentage === '-1' ? 'primary' : 'primary'}
/>
</TableCell>
<TableCell align="right">{formatSize(download.info.filesize_approx ?? 0)}</TableCell>
<TableCell align="right">
{new Date(download.info.created_at).toLocaleString()}
</TableCell>
<TableCell align="right">
<ButtonGroup>
<IconButton
size="small"
onClick={() => stop(download)}
>
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}
</IconButton>
{download.progress.percentage === '-1' &&
<>
<IconButton
size="small"
onClick={() => viewFile(download.output.savedFilePath)}
>
<SmartDisplayIcon />
</IconButton>
<IconButton
size="small"
onClick={() => downloadFile(download.output.savedFilePath)}
>
<FileDownloadIcon />
</IconButton>
</>
}
</ButtonGroup>
</TableCell>
</>
)
}
return (
<Box style={{ height: downloads.length === 0 ? '0vh' : '80vh', width: '100%' }}>
<TableVirtuoso
hidden={downloads.length === 0}
data={downloads}
components={VirtuosoTableComponents}
fixedHeaderContent={fixedHeaderContent}
itemContent={rowContent}
/>
</Box>
)
}
export default DownloadsTableView

View File

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

View File

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

View File

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

View File

@@ -101,7 +101,6 @@ export default function FormatsGrid({
> >
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec} {format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
{(format.filesize_approx > 0) ? " (~" + Math.round(format.filesize_approx / 1024 / 1024) + " MiB)" : ""} {(format.filesize_approx > 0) ? " (~" + Math.round(format.filesize_approx / 1024 / 1024) + " MiB)" : ""}
{format.language}
</Button> </Button>
</Grid> </Grid>
)) ))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
import { Chip, CircularProgress } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { ytdlpVersionState } from '../atoms/status'
const VersionIndicator: React.FC = () => {
const version = useRecoilValue(ytdlpVersionState)
return (
version
? <Chip label={`yt-dlp v${version}`} variant="outlined" size="small" />
: <CircularProgress size={15} />
)
}
export default VersionIndicator

View File

@@ -1,125 +0,0 @@
import CloseIcon from '@mui/icons-material/Close'
import {
Alert,
AppBar,
Box,
Button,
Container,
Dialog,
Grid,
IconButton,
Paper,
Slide,
TextField,
Toolbar,
Typography
} from '@mui/material'
import { TransitionProps } from '@mui/material/transitions'
import { forwardRef, useState } from 'react'
import { useToast } from '../../hooks/toast'
import { useI18n } from '../../hooks/useI18n'
import { useRPC } from '../../hooks/useRPC'
type Props = {
open: boolean
onClose: () => void
}
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />
})
const LivestreamDialog: React.FC<Props> = ({ open, onClose }) => {
const [livestreamURL, setLivestreamURL] = useState('')
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const exec = (url: string) => client.execLivestream(url)
return (
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={onClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Livestream monitor
</Typography>
</Toolbar>
</AppBar>
<Box sx={{
backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Container sx={{ my: 4 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
elevation={4}
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<Grid item xs={12} mb={2}>
<Alert severity="info">
{i18n.t('livestreamDownloadInfo')}
</Alert>
<Alert severity="warning" sx={{ mt: 1 }}>
{i18n.t('livestreamExperimentalWarning')}
</Alert>
</Grid>
<Grid item xs={12}>
<TextField
multiline
fullWidth
label={i18n.t('livestreamURLInput')}
variant="outlined"
onChange={(e) => setLivestreamURL(e.target.value)}
/>
</Grid>
<Grid item>
<Button
sx={{ mt: 2 }}
variant="contained"
disabled={livestreamURL === ''}
onClick={() => {
exec(livestreamURL)
onClose()
pushMessage(`Monitoring ${livestreamURL}`, 'info')
}}
>
{i18n.t('startButton')}
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Container>
</Box>
</Dialog>
)
}
export default LivestreamDialog

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,10 +9,6 @@ export type RPCMethods =
| "Service.ExecPlaylist" | "Service.ExecPlaylist"
| "Service.DirectoryTree" | "Service.DirectoryTree"
| "Service.UpdateExecutable" | "Service.UpdateExecutable"
| "Service.ExecLivestream"
| "Service.ProgressLivestream"
| "Service.KillLivestream"
| "Service.KillAllLivestream"
export type RPCRequest = { export type RPCRequest = {
method: RPCMethods method: RPCMethods
@@ -38,28 +34,17 @@ type DownloadInfo = {
created_at: string created_at: string
} }
export enum ProcessStatus {
PENDING = 0,
DOWNLOADING,
COMPLETED,
ERRORED,
LIVESTREAM,
}
type DownloadProgress = { type DownloadProgress = {
speed: number speed: number
eta: number eta: number
percentage: string percentage: string
process_status: ProcessStatus process_status: number
} }
export type RPCResult = Readonly<{ export type RPCResult = Readonly<{
id: string id: string
progress: DownloadProgress progress: DownloadProgress
info: DownloadInfo info: DownloadInfo
output: {
savedFilePath: string
}
}> }>
export type RPCParams = { export type RPCParams = {
@@ -82,37 +67,24 @@ export type DLFormat = {
vcodec: string vcodec: string
acodec: string acodec: string
filesize_approx: number filesize_approx: number
language: string
} }
export type DirectoryEntry = { export type DirectoryEntry = {
name: string name: string
path: string path: string
size: number size: number
shaSum: string
modTime: string modTime: string
isVideo: boolean isVideo: boolean
isDirectory: boolean isDirectory: boolean
} }
export type DeleteRequest = Pick<DirectoryEntry, 'path'> export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
export type PlayRequest = DeleteRequest export type PlayRequest = Pick<DirectoryEntry, 'path'>
export type CustomTemplate = { export type CustomTemplate = {
id: string id: string
name: string name: string
content: string content: string
} }
export enum LiveStreamStatus {
WAITING,
IN_PROGRESS,
COMPLETED,
ERRORED
}
export type LiveStreamProgress = Record<string, {
status: LiveStreamStatus
waitTime: string
liveDate: string
}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import {
Container, Container,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
FormGroup,
Grid, Grid,
InputAdornment, InputAdornment,
InputLabel, InputLabel,
@@ -11,14 +12,13 @@ import {
Paper, Paper,
Select, Select,
SelectChangeEvent, SelectChangeEvent,
Slider,
Stack, Stack,
Switch, Switch,
TextField, TextField,
Typography, Typography,
capitalize capitalize
} from '@mui/material' } from '@mui/material'
import { Suspense, useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useRecoilState } from 'recoil' import { useRecoilState } from 'recoil'
import { import {
Subject, Subject,
@@ -27,7 +27,6 @@ import {
map, map,
takeWhile takeWhile
} from 'rxjs' } from 'rxjs'
import { rpcPollingTimeState } from '../atoms/rpc'
import { import {
Language, Language,
Theme, Theme,
@@ -37,9 +36,9 @@ import {
formatSelectionState, formatSelectionState,
languageState, languageState,
languages, languages,
latestCliArgumentsState,
pathOverridingState, pathOverridingState,
servedFromReverseProxyState, servedFromReverseProxyState,
servedFromReverseProxySubDirState,
serverAddressState, serverAddressState,
serverPortState, serverPortState,
themeState themeState
@@ -48,25 +47,21 @@ import CookiesTextField from '../components/CookiesTextField'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser'
import { validateDomain, validateIP } from '../utils' import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS // NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() { export default function Settings() {
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState) const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
const [baseURL, setBaseURL] = useRecoilState(servedFromReverseProxySubDirState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState) const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState) const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState) const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState) const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState)
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState) const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
const [serverPort, setServerPort] = useRecoilState(serverPortState) const [serverPort, setServerPort] = useRecoilState(serverPortState)
const [pollingTime, setPollingTime] = useRecoilState(rpcPollingTimeState)
const [language, setLanguage] = useRecoilState(languageState) const [language, setLanguage] = useRecoilState(languageState)
const [appTitle, setApptitle] = useRecoilState(appTitleState) const [appTitle, setApptitle] = useRecoilState(appTitleState)
const [cliArgs, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [theme, setTheme] = useRecoilState(themeState) const [theme, setTheme] = useRecoilState(themeState)
const [invalidIP, setInvalidIP] = useState(false) const [invalidIP, setInvalidIP] = useState(false)
@@ -76,20 +71,11 @@ export default function Settings() {
const { pushMessage } = useToast() const { pushMessage } = useToast()
const baseURL$ = useMemo(() => new Subject<string>(), []) const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), []) const serverPort$ = useMemo(() => new Subject<string>(), [])
useEffect(() => {
const sub = baseURL$
.pipe(debounceTime(500))
.subscribe(baseURL => {
setBaseURL(baseURL)
pushMessage(i18n.t('restartAppMessage'), 'info')
})
return () => sub.unsubscribe()
}, [])
useEffect(() => { useEffect(() => {
const sub = serverAddr$ const sub = serverAddr$
.pipe( .pipe(
@@ -144,22 +130,25 @@ export default function Settings() {
* Updates yt-dlp binary via RPC * Updates yt-dlp binary via RPC
*/ */
const updateBinary = () => { const updateBinary = () => {
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'), 'success')) client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated')))
} }
return ( return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}> <Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={12} lg={12}>
<Paper <Paper
sx={{ sx={{
p: 2.5, p: 2,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 240, minHeight: 240,
}} }}
> >
<Typography pb={2} variant="h6" color="primary"> <Typography pb={3} variant="h5" color="primary">
{i18n.t('settingsAnchor')} {i18n.t('settingsAnchor')}
</Typography> </Typography>
<FormGroup>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={11}> <Grid item xs={12} md={11}>
<TextField <TextField
@@ -193,38 +182,7 @@ export default function Settings() {
error={appTitle === ''} error={appTitle === ''}
/> />
</Grid> </Grid>
<Grid item xs={12} md={12}>
<Typography>
{i18n.t('rpcPollingTimeTitle')}
</Typography>
<Typography variant='caption' sx={{ mb: 0.5 }}>
{i18n.t('rpcPollingTimeDescription')}
</Typography>
<Slider
aria-label="rpc polling time"
defaultValue={pollingTime}
max={2000}
getAriaValueText={(v: number) => `${v} ms`}
step={null}
valueLabelDisplay="off"
marks={[
{ value: 100, label: '100 ms' },
{ value: 250, label: '250 ms' },
{ value: 500, label: '500 ms' },
{ value: 750, label: '750 ms' },
{ value: 1000, label: '1000 ms' },
{ value: 2000, label: '2000 ms' },
]}
onChange={(_, value) => typeof value === 'number'
? setPollingTime(value)
: setPollingTime(1000)
}
/>
</Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h6" color="primary" sx={{ mb: 0.5 }}>
Reverse Proxy
</Typography>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
@@ -233,29 +191,10 @@ export default function Settings() {
/> />
} }
label={i18n.t('servedFromReverseProxyCheckbox')} label={i18n.t('servedFromReverseProxyCheckbox')}
sx={{ mb: 1 }}
/>
<TextField
fullWidth
label={i18n.t('urlBase')}
defaultValue={baseURL}
onChange={(e) => {
let value = e.currentTarget.value
if (value.startsWith('/')) {
value = value.substring(1)
}
if (value.endsWith('/')) {
value = value.substring(0, value.length - 1)
}
baseURL$.next(value)
}}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> />
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 0.5, mb: 2 }}>
Appearance
</Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<FormControl fullWidth> <FormControl fullWidth>
@@ -265,7 +204,7 @@ export default function Settings() {
label={i18n.t('languageSelect')} label={i18n.t('languageSelect')}
onChange={handleLanguageChange} onChange={handleLanguageChange}
> >
{languages.toSorted((a, b) => a.localeCompare(b)).map(l => ( {languages.map(l => (
<MenuItem value={l} key={l}> <MenuItem value={l} key={l}>
{capitalize(l)} {capitalize(l)}
</MenuItem> </MenuItem>
@@ -288,15 +227,32 @@ export default function Settings() {
</FormControl> </FormControl>
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}> <FormControlLabel
General download settings control={
</Typography> <Switch
defaultChecked={argsBuilder.noMTime}
onChange={() => setCliArgs(argsBuilder.toggleNoMTime().toString())}
/>
}
label={i18n.t('noMTimeCheckbox')}
sx={{ mt: 3 }}
/>
<FormControlLabel
control={
<Switch
defaultChecked={argsBuilder.extractAudio}
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
disabled={formatSelection}
/>
}
label={i18n.t('extractAudioCheckbox')}
/>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={formatSelection} defaultChecked={formatSelection}
onChange={() => { onChange={() => {
setCliArgs(argsBuilder.disableExtractAudio().toString())
setFormatSelection(!formatSelection) setFormatSelection(!formatSelection)
}} }}
/> />
@@ -343,13 +299,11 @@ export default function Settings() {
/> />
</Stack> </Stack>
</Grid> </Grid>
<Grid sx={{ mr: 1, mt: 2 }}> <Grid sx={{ mr: 1, mt: 3 }}>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}> <Typography variant="h6" color="primary" sx={{ mb: 2 }}>
Cookies Cookies
</Typography> </Typography>
<Suspense>
<CookiesTextField /> <CookiesTextField />
</Suspense>
</Grid> </Grid>
<Grid> <Grid>
<Stack direction="row"> <Stack direction="row">
@@ -362,7 +316,10 @@ export default function Settings() {
</Button> </Button>
</Stack> </Stack>
</Grid> </Grid>
</FormGroup>
</Paper> </Paper>
</Grid>
</Grid>
</Container> </Container>
) )
} }

View File

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

49
go.mod
View File

@@ -1,42 +1,43 @@
module github.com/marcopeocchi/yt-dlp-web-ui module github.com/marcopeocchi/yt-dlp-web-ui
go 1.23 go 1.20
require ( require (
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef github.com/go-chi/chi/v5 v5.0.11
github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.5.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.1
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
github.com/reactivex/rxgo/v2 v2.5.0 github.com/reactivex/rxgo/v2 v2.5.0
golang.org/x/oauth2 v0.23.0 golang.org/x/sys v0.15.0
golang.org/x/sync v0.8.0
golang.org/x/sys v0.25.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.32.0 modernc.org/sqlite v1.28.0
) )
require ( require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.12.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.1.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect github.com/stretchr/testify v1.4.0 // indirect
github.com/teivah/onecontext v1.3.0 // indirect github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 // indirect
golang.org/x/crypto v0.26.0 // indirect golang.org/x/mod v0.14.0 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect golang.org/x/net v0.19.0 // indirect
modernc.org/libc v1.60.1 // 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/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.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/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
) )

118
go.sum
View File

@@ -1,124 +1,100 @@
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM= github.com/cenkalti/backoff/v4 v4.0.0 h1:6VeaLF9aI+MAUQ95106HwWzYZgJJpZ4stumjj6RFYAU=
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4= github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4=
github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU= github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 h1:BLNsFR8l/hj/oGjnJXkd4Vi3s4kQD3/3x8HSAE4bzN0=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ= github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ=
github.com/teivah/onecontext v1.3.0 h1:tbikMhAlo6VhAuEGCvhc8HlTnpX4xTNPTOseWuhO1J0=
github.com/teivah/onecontext v1.3.0/go.mod h1:hoW1nmdPVK/0jrvGtcx8sCKYs2PiS4z0zzfdeuEVyb0=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= 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= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/ccgo/v4 v4.20.7 h1:skrinQsjxWfvj6nbC3ztZPJy+NuwmB3hV9zX/pthNYQ= modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/ccgo/v4 v4.20.7/go.mod h1:UOkI3JSG2zT4E2ioHlncSOZsXbuDCZLvPi3uMlZT5GY= modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/libc v1.38.0 h1:o4Lpk0zNDSdsjfEXnF1FGXWQ9PDi1NOdWcLP5n13FGo=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M= modernc.org/libc v1.38.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.59.9 h1:k+nNDDakwipimgmJ1D9H466LhFeSkaPPycAs1OZiDmY=
modernc.org/libc v1.59.9/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A=
modernc.org/libc v1.60.1 h1:at373l8IFRTkJIkAU85BIuUoBM4T1b51ds0E1ovPG2s=
modernc.org/libc v1.60.1/go.mod h1:xJuobKuNxKH3RUatS7GjR+suWj+5c2K7bi4m/S5arOY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=

31
main.go
View File

@@ -11,7 +11,6 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server" "github.com/marcopeocchi/yt-dlp-web-ui/server"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
) )
var ( var (
@@ -31,21 +30,16 @@ var (
userFromEnv = os.Getenv("USERNAME") userFromEnv = os.Getenv("USERNAME")
passFromEnv = os.Getenv("PASSWORD") passFromEnv = os.Getenv("PASSWORD")
logFile string
enableFileLogging bool
//go:embed frontend/dist/index.html //go:embed frontend/dist/index.html
//go:embed frontend/dist/assets/* //go:embed frontend/dist/assets/*
frontend embed.FS frontend embed.FS
//go:embed openapi/*
swagger embed.FS
) )
func init() { func init() {
flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at") flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at")
flag.IntVar(&port, "port", 3033, "Port where server will listen at") flag.IntVar(&port, "port", 3033, "Port where server will listen at")
flag.IntVar(&queueSize, "qs", 2, "Queue size (concurrent downloads)") flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size")
flag.StringVar(&configFile, "conf", "./config.yml", "Config file path") flag.StringVar(&configFile, "conf", "./config.yml", "Config file path")
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
@@ -53,9 +47,6 @@ func init() {
flag.StringVar(&sessionFilePath, "session", ".", "session file path") flag.StringVar(&sessionFilePath, "session", ".", "session file path")
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path") flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file")
flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location")
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication") flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
flag.StringVar(&username, "user", userFromEnv, "Username required for auth") flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
flag.StringVar(&password, "pass", passFromEnv, "Password required for auth") flag.StringVar(&password, "pass", passFromEnv, "Password required for auth")
@@ -65,6 +56,7 @@ func init() {
func main() { func main() {
frontend, err := fs.Sub(frontend, "frontend/dist") frontend, err := fs.Sub(frontend, "frontend/dist")
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
@@ -82,25 +74,10 @@ func main() {
c.Username = username c.Username = username
c.Password = password c.Password = password
// limit concurrent downloads for systems with 2 or less logical cores
if runtime.NumCPU() <= 2 {
c.QueueSize = 1
}
// if config file is found it will be merged with the current config struct // if config file is found it will be merged with the current config struct
if err := c.LoadFile(configFile); err != nil { if err := c.LoadFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, err) log.Println(cli.BgRed, "config", cli.Reset, err)
} }
openid.Configure() server.RunBlocking(c.Host, c.Port, frontend, localDatabasePath)
server.RunBlocking(&server.RunConfig{
Host: c.Host,
Port: c.Port,
DBPath: localDatabasePath,
FileLogging: enableFileLogging,
LogFile: logFile,
App: frontend,
Swagger: swagger,
})
} }

View File

@@ -1,9 +0,0 @@
{ lib }: {
version = "v3.1.2";
meta = {
description = "A terrible web ui for yt-dlp. Designed to be self-hosted.";
homepage = "https://github.com/marcopeocchi/yt-dlp-web-ui";
license = lib.licenses.mpl20;
};
}

View File

@@ -1,9 +0,0 @@
{ inputsFrom ? [ ], mkShell, yt-dlp, nodejs, go }:
mkShell {
inherit inputsFrom;
packages = [
yt-dlp
nodejs
go
];
}

View File

@@ -1,37 +0,0 @@
{ lib
, stdenv
, nodejs
, pnpm
}:
let common = import ./common.nix { inherit lib; }; in
stdenv.mkDerivation (finalAttrs: {
pname = "yt-dlp-web-ui-frontend";
inherit (common) version;
src = lib.fileset.toSource {
root = ../frontend;
fileset = ../frontend;
};
buildPhase = ''
npm run build
'';
installPhase = ''
mkdir -p $out/dist
cp -r dist/* $out/dist
'';
nativeBuildInputs = [
nodejs
pnpm.configHook
];
pnpmDeps = pnpm.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-NvXNDXkuoJ4vGeQA3bOhhc+KLBfke593qK0edcvzWTo=";
};
inherit (common) meta;
})

View File

@@ -1,215 +0,0 @@
packages: { config, lib, pkgs, ... }:
let
cfg = config.services.yt-dlp-web-ui;
inherit (pkgs.stdenv.hostPlatform) system;
pkg = packages.${system}.default;
in
{
/*
Some notes on the module design:
- Usually, you don't map out all of the options like this in attrsets,
but due to the software's nonstandard "config file overrides CLI" behavior,
we don't want to expose a config file catchall, and as such don't use '-conf'.
- Notably, '-driver' is missing as a configuration option.
This should instead be customized with idiomatic Nix, overriding 'cfg.package' with
the desired yt-dlp package.
- The systemd service has been sandboxed as much as possible. This restricts configuration of
data and logs dir. If you really need a custom data and logs dir, use BindPaths (man systemd.exec)
*/
options.services.yt-dlp-web-ui = {
enable = lib.mkEnableOption "yt-dlp-web-ui";
package = lib.mkOption {
type = lib.types.package;
default = pkg;
defaultText = lib.literalMD "`packages.default` from the yt-dlp-web-ui flake.";
description = ''
The yt-dlp-web-ui package to use.
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "yt-dlp-web-ui";
description = lib.mdDoc ''
User under which yt-dlp-web-ui runs.
'';
};
group = lib.mkOption {
type = lib.types.str;
default = "yt-dlp-web-ui";
description = lib.mdDoc ''
Group under which yt-dlp-web-ui runs.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = lib.mdDoc ''
Whether to open the TCP port in the firewall.
'';
};
host = lib.mkOption {
default = "0.0.0.0";
type = lib.types.str;
description = lib.mdDoc ''
Host where yt-dlp-web-ui will listen at.
'';
};
port = lib.mkOption {
default = 3033;
type = lib.types.port;
description = lib.mdDoc ''
Port where yt-dlp-web-ui will listen at.
'';
};
downloadDir = lib.mkOption {
type = lib.types.str;
description = lib.mdDoc ''
The directory where yt-dlp-web-ui stores downloads.
'';
};
queueSize = lib.mkOption {
default = 2;
type = lib.types.ints.unsigned; # >= 0
description = lib.mdDoc ''
Queue size (concurrent downloads).
'';
};
logging = lib.mkEnableOption "logging";
rpcAuth = lib.mkOption {
description = lib.mdDoc ''
RPC Authentication settings.
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "RPC authentication";
user = lib.mkOption {
type = lib.types.str;
description = lib.mdDoc ''
Username required for auth.
'';
};
passwordFile = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = lib.mdDoc ''
Path to the file containing the password required for auth.
'';
};
insecurePasswordText = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = lib.mdDoc ''
Raw password required for auth.
It's strongly recommended to use 'passwordFile' instead of this option.
**Don't use this option unless you know what you're doing!**.
It writes the password to the world-readable Nix store, which is a big security risk.
More info: https://wiki.nixos.org/wiki/Comparison_of_secret_managing_schemes
'';
};
};
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
(lib.mkIf cfg.rpcAuth.enable {
assertion = lib.xor (cfg.rpcAuth.passwordFile == null) (cfg.rpcAuth.insecurePasswordText == null);
message = ''
RPC Auth is enabled for yt-dlp-web-ui! Exactly one RPC auth password source must be set!
Tip: You should set 'services.yt-dlp-web-ui.rpcAuth.passwordfile'!
'';
})
];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
users.users = lib.mkIf (cfg.user == "yt-dlp-web-ui") {
yt-dlp-web-ui = {
inherit (cfg) group;
isSystemUser = true;
};
};
users.groups = lib.mkIf (cfg.group == "yt-dlp-web-ui") { yt-dlp-web-ui = { }; };
systemd.services.yt-dlp-web-ui = {
description = "yt-dlp-web-ui system service";
after = [ "network.target" ];
path = [ cfg.package pkgs.tree ];
wantedBy = [ "multi-user.target" ];
serviceConfig =
rec {
ExecStart =
let
password =
if cfg.rpcAuth.passwordFile == null
then cfg.rpcAuth.insecurePasswordText
else "$(cat ${cfg.rpcAuth.passwordFile})";
args = [
"-host ${cfg.host}"
"-port ${builtins.toString cfg.port}"
''-out "${cfg.downloadDir}"''
"-qs ${builtins.toString cfg.queueSize}"
] ++ (lib.optionals cfg.logging [
"-fl"
''-lf "/var/log/${LogsDirectory}/yt-dlp-web-ui.log"''
]) ++ (lib.optionals cfg.rpcAuth.enable [
"-auth"
"-user ${cfg.rpcAuth.user}"
"-pass ${password}"
]);
in
"${lib.getExe cfg.package} ${lib.concatStringsSep " " args}";
User = cfg.user;
Group = cfg.group;
ProtectSystem = "strict";
ProtectHome = "read-only";
StateDirectory = "yt-dlp-web-ui";
WorkingDirectory = "/var/lib/${StateDirectory}"; # equivalent to the dir above
LogsDirectory = "yt-dlp-web-ui";
ReadWritePaths = [
cfg.downloadDir
];
BindReadOnlyPaths = [
builtins.storeDir
# required for youtube DNS lookup
"${config.environment.etc."ssl/certs/ca-certificates.crt".source}:/etc/ssl/certs/ca-certificates.crt"
] ++ lib.optionals (cfg.rpcAuth.enable && cfg.rpcAuth.passwordFile != null) [
cfg.rpcAuth.passwordFile
];
CapabilityBoundingSet = "";
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
PrivateDevices = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged" ];
RestrictRealtime = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
ProtectHostname = true;
};
};
};
}

View File

@@ -1,52 +0,0 @@
{ yt-dlp-web-ui-frontend, buildGoModule, lib, makeWrapper, yt-dlp, ... }:
let
fs = lib.fileset;
common = import ./common.nix { inherit lib; };
in
buildGoModule {
pname = "yt-dlp-web-ui";
inherit (common) version;
src = fs.toSource rec {
root = ../.;
fileset = fs.difference root (fs.unions [
### LIST OF FILES TO IGNORE ###
# frontend (this is included by the frontend.nix drv instead)
../frontend
# documentation
../examples
# docker
../Dockerfile
../docker-compose.yml
# nix
./devShell.nix
../.envrc
./tests
# make
../Makefile # this derivation does not use the project Makefile
# repo commons
../.github
../README.md
../LICENSE.md
../.gitignore
../.vscode
]);
};
# https://github.com/golang/go/issues/44507
preBuild = ''
cp -r ${yt-dlp-web-ui-frontend} frontend
'';
nativeBuildInputs = [ makeWrapper ];
postInstall = ''
wrapProgram $out/bin/yt-dlp-web-ui \
--prefix PATH : ${lib.makeBinPath [ yt-dlp ]}
'';
vendorHash = "sha256-guM/U9DROJMx2ctPKBQis1YRhaf6fKvvwEWgswQKMG0=";
meta = common.meta // {
mainProgram = "yt-dlp-web-ui";
};
}

View File

@@ -1,20 +0,0 @@
{ self, pkgs }: {
testServiceStarts = pkgs.testers.runNixOSTest (_: {
name = "service-starts";
nodes = {
machine = _: {
imports = [
self.nixosModules.default
];
services.yt-dlp-web-ui = {
enable = true;
downloadDir = "/var/lib/yt-dlp-web-ui";
};
};
};
testScript = ''
machine.wait_for_unit("yt-dlp-web-ui")
'';
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,58 +0,0 @@
package dbutil
import (
"context"
"database/sql"
"os"
"path/filepath"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock")
// Run the table migration
func Migrate(ctx context.Context, db *sql.DB) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer func() {
conn.Close()
createLockFile()
}()
if _, err := db.ExecContext(
ctx,
`CREATE TABLE IF NOT EXISTS templates (
id CHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content TEXT NOT NULL
)`,
); err != nil {
return err
}
if lockFileExists() {
return nil
}
db.ExecContext(
ctx,
`INSERT INTO templates (id, name, content) VALUES
($1, $2, $3),
($4, $5, $6);`,
"0", "default", "--no-mtime",
"1", "audio only", "-x",
)
return nil
}
func createLockFile() { os.Create(lockFilePath) }
func lockFileExists() bool {
_, err := os.Stat(lockFilePath)
return os.IsExist(err)
}

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

View File

@@ -8,19 +8,18 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
) )
const TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui"
type LoginRequest struct { type LoginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
func Login(w http.ResponseWriter, r *http.Request) { func Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest req := new(LoginRequest)
err := json.NewDecoder(r.Body).Decode(req)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -56,7 +55,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
func Logout(w http.ResponseWriter, r *http.Request) { func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{ cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME, Name: utils.TOKEN_COOKIE_NAME,
HttpOnly: true, HttpOnly: true,
Secure: false, Secure: false,
Expires: time.Now(), Expires: time.Now(),

View File

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

View File

@@ -2,21 +2,6 @@ package internal
import "time" import "time"
// Used to unmarshall yt-dlp progress
type ProgressTemplate struct {
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
Size string `json:"size"`
Eta float32 `json:"eta"`
}
// Defines where and how the download needs to be saved
type DownloadOutput struct {
Path string
Filename string
SavedFilePath string `json:"savedFilePath"`
}
// Progress for the Running call // Progress for the Running call
type DownloadProgress struct { type DownloadProgress struct {
Status int `json:"process_status"` Status int `json:"process_status"`
@@ -36,7 +21,6 @@ type DownloadInfo struct {
ACodec string `json:"acodec"` ACodec string `json:"acodec"`
Extension string `json:"ext"` Extension string `json:"ext"`
OriginalURL string `json:"original_url"` OriginalURL string `json:"original_url"`
FileName string `json:"filename"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -58,7 +42,6 @@ type Format struct {
VCodec string `json:"vcodec"` VCodec string `json:"vcodec"`
ACodec string `json:"acodec"` ACodec string `json:"acodec"`
Size float32 `json:"filesize_approx"` Size float32 `json:"filesize_approx"`
Language string `json:"language"`
} }
// struct representing the response sent to the client // struct representing the response sent to the client
@@ -96,7 +79,6 @@ type SetCookiesRequest struct {
Cookies string `json:"cookies"` Cookies string `json:"cookies"`
} }
// represents a user defined collection of yt-dlp arguments
type CustomTemplate struct { type CustomTemplate struct {
Id string `json:"id"` Id string `json:"id"`
Name string `json:"name"` Name string `json:"name"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ package internal
import ( import (
"encoding/gob" "encoding/gob"
"errors" "errors"
"fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -13,81 +15,84 @@ import (
// In-Memory Thread-Safe Key-Value Storage with optional persistence // In-Memory Thread-Safe Key-Value Storage with optional persistence
type MemoryDB struct { type MemoryDB struct {
table map[string]*Process table sync.Map
mu sync.RWMutex
}
func NewMemoryDB() *MemoryDB {
return &MemoryDB{
table: make(map[string]*Process),
}
} }
// Get a process pointer given its id // Get a process pointer given its id
func (m *MemoryDB) Get(id string) (*Process, error) { func (m *MemoryDB) Get(id string) (*Process, error) {
m.mu.RLock() entry, ok := m.table.Load(id)
defer m.mu.RUnlock()
entry, ok := m.table[id]
if !ok { if !ok {
return nil, errors.New("no process found for the given key") return nil, errors.New("no process found for the given key")
} }
return entry.(*Process), nil
return entry, nil
} }
// Store a pointer of a process and return its id // Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string { func (m *MemoryDB) Set(process *Process) string {
id := uuid.NewString() id := uuid.NewString()
m.table.Store(id, process)
m.mu.Lock()
process.Id = id process.Id = id
m.table[id] = process
m.mu.Unlock()
return id return id
} }
// Update a process info/metadata, given the process id
//
// Deprecated: will be removed anytime soon.
func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error {
entry, ok := m.table.Load(id)
if ok {
entry.(*Process).Info = info
m.table.Store(id, entry)
return nil
}
return fmt.Errorf("can't update row with id %s", id)
}
// Update a process progress data, given the process id
// Used for updating completition percentage or ETA.
//
// Deprecated: will be removed anytime soon.
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
entry, ok := m.table.Load(id)
if ok {
entry.(*Process).Progress = progress
m.table.Store(id, entry)
return nil
}
return fmt.Errorf("can't update row with id %s", id)
}
// Removes a process progress, given the process id // Removes a process progress, given the process id
func (m *MemoryDB) Delete(id string) { func (m *MemoryDB) Delete(id string) {
m.mu.Lock() m.table.Delete(id)
delete(m.table, id)
m.mu.Unlock()
} }
func (m *MemoryDB) Keys() *[]string { func (m *MemoryDB) Keys() *[]string {
var running []string running := []string{}
m.table.Range(func(key, value any) bool {
m.mu.RLock() running = append(running, key.(string))
defer m.mu.RUnlock() return true
})
for id := range m.table {
running = append(running, id)
}
return &running return &running
} }
// Returns a slice of all currently stored processes progess // Returns a slice of all currently stored processes progess
func (m *MemoryDB) All() *[]ProcessResponse { func (m *MemoryDB) All() *[]ProcessResponse {
running := []ProcessResponse{} running := []ProcessResponse{}
m.table.Range(func(key, value any) bool {
m.mu.RLock()
for k, v := range m.table {
running = append(running, ProcessResponse{ running = append(running, ProcessResponse{
Id: k, Id: key.(string),
Info: v.Info, Info: value.(*Process).Info,
Progress: v.Progress, Progress: value.(*Process).Progress,
Output: v.Output, Output: value.(*Process).Output,
Params: v.Params, Params: value.(*Process).Params,
})
return true
}) })
}
m.mu.RUnlock()
return &running return &running
} }
// Persist the database in a single file named "session.dat" // WIP: Persist the database in a single file named "session.dat"
func (m *MemoryDB) Persist() error { func (m *MemoryDB) Persist() error {
running := m.All() running := m.All()
@@ -98,33 +103,32 @@ func (m *MemoryDB) Persist() error {
return errors.Join(errors.New("failed to persist session"), err) return errors.Join(errors.New("failed to persist session"), err)
} }
m.mu.RLock() session := Session{
defer m.mu.RUnlock() Processes: *running,
session := Session{Processes: *running} }
if err := gob.NewEncoder(fd).Encode(session); err != nil { err = gob.NewEncoder(fd).Encode(session)
if err != nil {
return errors.Join(errors.New("failed to persist session"), err) return errors.Join(errors.New("failed to persist session"), err)
} }
return nil return nil
} }
// Restore a persisted state // WIP: Restore a persisted state
func (m *MemoryDB) Restore(mq *MessageQueue) { func (m *MemoryDB) Restore(logger *slog.Logger) {
fd, err := os.Open("session.dat") fd, err := os.Open("session.dat")
if err != nil { if err != nil {
return return
} }
var session Session session := Session{}
if err := gob.NewDecoder(fd).Decode(&session); err != nil { err = gob.NewDecoder(fd).Decode(&session)
if err != nil {
return return
} }
m.mu.Lock()
defer m.mu.Unlock()
for _, proc := range session.Processes { for _, proc := range session.Processes {
restored := &Process{ restored := &Process{
Id: proc.Id, Id: proc.Id,
@@ -133,12 +137,13 @@ func (m *MemoryDB) Restore(mq *MessageQueue) {
Progress: proc.Progress, Progress: proc.Progress,
Output: proc.Output, Output: proc.Output,
Params: proc.Params, Params: proc.Params,
Logger: logger,
} }
m.table[proc.Id] = restored m.table.Store(proc.Id, restored)
if restored.Progress.Status != StatusCompleted { if restored.Progress.Percentage != "-1" {
mq.Publish(restored) go restored.Start()
} }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -2,24 +2,23 @@ package internal
import ( import (
"bufio" "bufio"
"bytes"
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"regexp" "regexp"
"slices"
"sync" "sync"
"syscall" "syscall"
"log"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
"github.com/marcopeocchi/fazzoletti/slices"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
) )
const template = `download: const template = `download:
@@ -36,16 +35,28 @@ const (
StatusErrored StatusErrored
) )
type ProgressTemplate struct {
Percentage string `json:"percentage"`
Speed float32 `json:"speed"`
Size string `json:"size"`
Eta float32 `json:"eta"`
}
// Process descriptor // Process descriptor
type Process struct { type Process struct {
Id string Id string
Url string Url string
Livestream bool
Params []string Params []string
Info DownloadInfo Info DownloadInfo
Progress DownloadProgress Progress DownloadProgress
Output DownloadOutput Output DownloadOutput
proc *os.Process proc *os.Process
Logger *slog.Logger
}
type DownloadOutput struct {
Path string
Filename string
} }
// Starts spawns/forks a new yt-dlp process and parse its stdout. // Starts spawns/forks a new yt-dlp process and parse its stdout.
@@ -56,13 +67,13 @@ type Process struct {
func (p *Process) Start() { func (p *Process) Start() {
// escape bash variable escaping and command piping, you'll never know // escape bash variable escaping and command piping, you'll never know
// what they might come with... // what they might come with...
p.Params = slices.DeleteFunc(p.Params, func(e string) bool { p.Params = slices.Filter(p.Params, func(e string) bool {
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e) match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
return match return !match
}) })
p.Params = slices.DeleteFunc(p.Params, func(e string) bool { p.Params = slices.Filter(p.Params, func(e string) bool {
return e == "" return e != ""
}) })
out := DownloadOutput{ out := DownloadOutput{
@@ -80,10 +91,7 @@ func (p *Process) Start() {
buildFilename(&p.Output) buildFilename(&p.Output)
//TODO: it spawn another one yt-dlp process, too slow. params := []string{
go p.GetFileName(&out)
baseParams := []string{
strings.Split(p.Url, "?list")[0], //no playlist strings.Split(p.Url, "?list")[0], //no playlist
"--newline", "--newline",
"--no-colors", "--no-colors",
@@ -93,109 +101,83 @@ func (p *Process) Start() {
} }
// if user asked to manually override the output path... // if user asked to manually override the output path...
if !(slices.Contains(p.Params, "-P") || slices.Contains(p.Params, "--paths")) { if !(slices.Includes(params, "-P") || slices.Includes(params, "--paths")) {
p.Params = append(p.Params, "-o") params = append(params, "-o")
p.Params = append(p.Params, fmt.Sprintf("%s/%s", out.Path, out.Filename)) params = append(params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
} }
params := append(baseParams, p.Params...) params = append(params, p.Params...)
slog.Info("requesting download", slog.String("url", p.Url), slog.Any("params", params))
// ----------------- main block ----------------- //
cmd := exec.Command(config.Instance().DownloaderPath, params...) cmd := exec.Command(config.Instance().DownloaderPath, params...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe() r, err := cmd.StdoutPipe()
if err != nil { if err != nil {
slog.Error("failed to get a stdout pipe", slog.Any("err", err)) p.Logger.Error(
"failed to connect to stdout",
slog.String("err", err.Error()),
)
panic(err) panic(err)
} }
scan := bufio.NewScanner(r)
stderr, err := cmd.StderrPipe() err = cmd.Start()
if err != nil { if err != nil {
slog.Error("failed to get a stderr pipe", slog.Any("err", err)) p.Logger.Error(
panic(err) "failed to start yt-dlp process",
} slog.String("err", err.Error()),
)
if err := cmd.Start(); err != nil {
slog.Error("failed to start yt-dlp process", slog.Any("err", err))
panic(err) panic(err)
} }
p.proc = cmd.Process p.proc = cmd.Process
ctx, cancel := context.WithCancel(context.Background()) // --------------- progress block --------------- //
defer func() { var (
stdout.Close() sourceChan = make(chan []byte)
p.Complete() doneChan = make(chan struct{})
cancel()
}()
logs := make(chan []byte)
go produceLogs(stdout, logs)
go p.consumeLogs(ctx, logs)
go p.detectYtDlpErrors(stderr)
cmd.Wait()
}
func produceLogs(r io.Reader, logs chan<- []byte) {
go func() {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
logs <- scanner.Bytes()
}
}()
}
func (p *Process) consumeLogs(ctx context.Context, logs <-chan []byte) {
for {
select {
case <-ctx.Done():
slog.Info("detaching from yt-dlp stdout",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
) )
return
case entry := <-logs:
p.parseLogEntry(entry)
}
}
}
func (p *Process) parseLogEntry(entry []byte) { // spawn a goroutine that does the dirty job of parsing the stdout
var progress ProgressTemplate // filling the channel with as many stdout line as yt-dlp produces (producer)
go func() {
defer func() {
r.Close()
p.Complete()
doneChan <- struct{}{}
close(sourceChan)
close(doneChan)
}()
if err := json.Unmarshal(entry, &progress); err != nil { for scan.Scan() {
return sourceChan <- scan.Bytes()
} }
}()
// Slows down the unmarshal operation to every 500ms
go func() {
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
stdout := ProgressTemplate{}
err := json.Unmarshal(event, &stdout)
if err == nil {
p.Progress = DownloadProgress{ p.Progress = DownloadProgress{
Status: StatusDownloading, Status: StatusDownloading,
Percentage: progress.Percentage, Percentage: stdout.Percentage,
Speed: progress.Speed, Speed: stdout.Speed,
ETA: progress.Eta, ETA: stdout.Eta,
} }
p.Logger.Info("progress",
slog.Info("progress",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("percentage", progress.Percentage), slog.String("percentege", stdout.Percentage),
) )
} }
})
}()
func (p *Process) detectYtDlpErrors(r io.Reader) { // ------------- end progress block ------------- //
scanner := bufio.NewScanner(r) cmd.Wait()
for scanner.Scan() {
slog.Error("yt-dlp process error",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", scanner.Text()),
)
}
} }
// Keep process in the memoryDB but marks it as complete // Keep process in the memoryDB but marks it as complete
@@ -209,7 +191,7 @@ func (p *Process) Complete() {
ETA: 0, ETA: 0,
} }
slog.Info("finished", p.Logger.Info("finished",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
) )
@@ -217,22 +199,18 @@ func (p *Process) Complete() {
// Kill a process and remove it from the memory // Kill a process and remove it from the memory
func (p *Process) Kill() error { func (p *Process) Kill() error {
defer func() {
p.Progress.Status = StatusCompleted
}()
// yt-dlp uses multiple child process the parent process // yt-dlp uses multiple child process the parent process
// has been spawned with setPgid = true. To properly kill // has been spawned with setPgid = true. To properly kill
// all subprocesses a SIGTERM need to be sent to the correct // all subprocesses a SIGTERM need to be sent to the correct
// process group // process group
if p.proc == nil { if p.proc != nil {
return errors.New("*os.Process not set")
}
pgid, err := syscall.Getpgid(p.proc.Pid) pgid, err := syscall.Getpgid(p.proc.Pid)
if err != nil { if err != nil {
return err return err
} }
if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil { err = syscall.Kill(-pgid, syscall.SIGTERM)
p.Logger.Info("killed process", slog.String("id", p.Id))
return err return err
} }
@@ -240,23 +218,14 @@ func (p *Process) Kill() error {
} }
// Returns the available format for this URL // Returns the available format for this URL
// func (p *Process) GetFormatsSync() (DownloadFormats, error) {
// TODO: Move out from process.go
func (p *Process) GetFormats() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J") cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output() stdout, err := cmd.Output()
if err != nil { if err != nil {
slog.Error("failed to retrieve metadata", slog.String("err", err.Error()))
return DownloadFormats{}, err return DownloadFormats{}, err
} }
slog.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", p.Url),
)
info := DownloadFormats{URL: p.Url} info := DownloadFormats{URL: p.Url}
best := Format{} best := Format{}
@@ -267,10 +236,27 @@ func (p *Process) GetFormats() (DownloadFormats, error) {
wg.Add(2) wg.Add(2)
if err != nil {
return DownloadFormats{}, err
}
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, "Formats", cli.Reset,
p.Url,
)
p.Logger.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", p.Url),
)
go func() { go func() {
decodingError = json.Unmarshal(stdout, &info) decodingError = json.Unmarshal(stdout, &info)
wg.Done() wg.Done()
}() }()
go func() { go func() {
decodingError = json.Unmarshal(stdout, &best) decodingError = json.Unmarshal(stdout, &best)
wg.Done() wg.Done()
@@ -287,31 +273,7 @@ func (p *Process) GetFormats() (DownloadFormats, error) {
return info, nil return info, nil
} }
func (p *Process) GetFileName(o *DownloadOutput) error {
cmd := exec.Command(
config.Instance().DownloaderPath,
"--print", "filename",
"-o", fmt.Sprintf("%s/%s", o.Path, o.Filename),
p.Url,
)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
out, err := cmd.Output()
if err != nil {
return err
}
p.Output.SavedFilePath = strings.Trim(string(out), "\n")
return nil
}
func (p *Process) SetPending() { func (p *Process) SetPending() {
// Since video's title isn't available yet, fill in with the URL.
p.Info = DownloadInfo{
URL: p.Url,
Title: p.Url,
CreatedAt: time.Now(),
}
p.Progress.Status = StatusPending p.Progress.Status = StatusPending
} }
@@ -321,17 +283,7 @@ func (p *Process) SetMetadata() error {
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
slog.Error("failed to connect to stdout", p.Logger.Error("failed retrieving info",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", err.Error()),
)
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
slog.Error("failed to connect to stderr",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("err", err.Error()), slog.String("err", err.Error()),
@@ -344,37 +296,33 @@ func (p *Process) SetMetadata() error {
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := cmd.Start(); err != nil { err = cmd.Start()
if err != nil {
return err return err
} }
var bufferedStderr bytes.Buffer p.Logger.Info("retrieving metadata",
go func() {
io.Copy(&bufferedStderr, stderr)
}()
slog.Info("retrieving metadata",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
) )
if err := json.NewDecoder(stdout).Decode(&info); err != nil { err = json.NewDecoder(stdout).Decode(&info)
if err != nil {
return err return err
} }
p.Info = info p.Info = info
p.Progress.Status = StatusPending p.Progress.Status = StatusPending
if err := cmd.Wait(); err != nil { err = cmd.Wait()
return errors.New(bufferedStderr.String())
return err
} }
return nil func (p *Process) getShortId() string {
return strings.Split(p.Id, "-")[0]
} }
func (p *Process) getShortId() string { return strings.Split(p.Id, "-")[0] }
func buildFilename(o *DownloadOutput) { func buildFilename(o *DownloadOutput) {
if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") { if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") {
o.Filename += ".%(ext)s" o.Filename += ".%(ext)s"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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