Compare commits
64 Commits
224-editab
...
server-v4
| Author | SHA1 | Date | |
|---|---|---|---|
| 991bea1a7b | |||
| 5dbe6d886f | |||
|
|
8c06485880 | ||
| 658d43f9ea | |||
|
|
ccb6bbe3e6 | ||
| 4c35b0b41f | |||
| 9ca7bb9377 | |||
| bce696fc67 | |||
| 22caf8899b | |||
| 2a11f64935 | |||
|
|
f4a0f688af | ||
|
|
14a03d6a77 | ||
|
|
8a73079fad | ||
| f578f44cfd | |||
| cbe16c5c6c | |||
| 3cebaf7f61 | |||
|
|
2d2cb1dc3a | ||
|
|
43bcc40907 | ||
|
|
2af27e51be | ||
|
|
8c18242aaf | ||
|
|
66bebb2529 | ||
|
|
e223e030ac | ||
| e4362468f7 | |||
| 6880f60d14 | |||
|
|
5d4aa7e2a3 | ||
|
|
2845196bc7 | ||
|
|
983915f8aa | ||
| ce2fb13ef2 | |||
| 99069fe5f7 | |||
| 761f26b387 | |||
| eec72bb6e2 | |||
| ceb92d066c | |||
|
|
cf74948840 | ||
|
|
1c62084c7b | ||
| 3c21253562 | |||
| b243c1c958 | |||
|
|
7be5bc7b1f | ||
|
|
65960fb560 | ||
|
|
1141903512 | ||
| ff93bd552f | |||
|
|
016d8557e6 | ||
| 5e9f92a06f | |||
| cc6a562e9e | |||
|
|
67b01f9e0b | ||
|
|
2f2eca2bff | ||
|
|
5073113568 | ||
|
|
430bfabfb4 | ||
| 160a2721f9 | |||
|
|
6f0187bccc | ||
|
|
801c89df5d | ||
|
|
49fdaeb42a | ||
| fc07c08c58 | |||
| f9e829dce6 | |||
| 17fb608f45 | |||
| 9d3861ab39 | |||
| d9cb018132 | |||
|
|
ac077ea1e1 | ||
| f29d719df0 | |||
|
|
6adfa71fde | ||
| 0946d374e3 | |||
|
|
f68c29f838 | ||
|
|
c46e39e736 | ||
|
|
2885d6b5d8 | ||
|
|
ab7932ae92 |
1
.deploystack/docker-run.txt
Normal file
1
.deploystack/docker-run.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
docker run -d -p 3033:3033 -v /downloads:/downloads marcobaobao/yt-dlp-webui
|
||||||
27
.devcontainer/devcontainer.json
Normal file
27
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/go
|
||||||
|
{
|
||||||
|
"name": "Go",
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers-extra/features/pnpm:2": {},
|
||||||
|
"ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {},
|
||||||
|
"ghcr.io/devcontainers-extra/features/yt-dlp:2": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
// "features": {},
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
// "postCreateCommand": "go version"
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
// "customizations": {},
|
||||||
|
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
result/
|
result/
|
||||||
result
|
result
|
||||||
dist
|
dist
|
||||||
|
.pnpm-store/
|
||||||
.pnpm-debug.log
|
.pnpm-debug.log
|
||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
@@ -20,9 +21,11 @@ cookies.txt
|
|||||||
__debug*
|
__debug*
|
||||||
ui/
|
ui/
|
||||||
.idea
|
.idea
|
||||||
|
.idea/
|
||||||
frontend/.pnp.cjs
|
frontend/.pnp.cjs
|
||||||
frontend/.pnp.loader.mjs
|
frontend/.pnp.loader.mjs
|
||||||
frontend/.yarn/install-state.gz
|
frontend/.yarn/install-state.gz
|
||||||
.db.lock
|
.db.lock
|
||||||
livestreams.dat
|
livestreams.dat
|
||||||
.git
|
.vite/deps
|
||||||
|
archive.txt
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
result/
|
result/
|
||||||
result
|
result
|
||||||
dist
|
dist
|
||||||
|
.pnpm-store/
|
||||||
.pnpm-debug.log
|
.pnpm-debug.log
|
||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
@@ -20,8 +21,12 @@ cookies.txt
|
|||||||
__debug*
|
__debug*
|
||||||
ui/
|
ui/
|
||||||
.idea
|
.idea
|
||||||
|
.idea/
|
||||||
frontend/.pnp.cjs
|
frontend/.pnp.cjs
|
||||||
frontend/.pnp.loader.mjs
|
frontend/.pnp.loader.mjs
|
||||||
frontend/.yarn/install-state.gz
|
frontend/.yarn/install-state.gz
|
||||||
.db.lock
|
.db.lock
|
||||||
livestreams.dat
|
livestreams.dat
|
||||||
|
.vite/deps
|
||||||
|
archive.txt
|
||||||
|
twitch-monitor.dat
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -1,8 +1,8 @@
|
|||||||
# Node (pnpm) ------------------------------------------------------------------
|
# Node (pnpm) ------------------------------------------------------------------
|
||||||
FROM node:20-slim AS ui
|
FROM node:22-slim AS ui
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack prepare pnpm@10.0.0 --activate && 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
|
||||||
@@ -24,11 +24,12 @@ 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 ----------------------------------------------------------------
|
# Runtime ---------------------------------------------------------------------
|
||||||
FROM alpine:edge
|
FROM python:3.13.2-alpine3.21
|
||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add ffmpeg yt-dlp ca-certificates curl wget psmisc
|
apk add ffmpeg ca-certificates curl wget gnutls --no-cache && \
|
||||||
|
pip install "yt-dlp[default,curl-cffi,mutagen,pycryptodomex,phantomjs,secretstorage]"
|
||||||
|
|
||||||
VOLUME /downloads /config
|
VOLUME /downloads /config
|
||||||
|
|
||||||
|
|||||||
52
README.md
52
README.md
@@ -1,3 +1,7 @@
|
|||||||
|
> [!NOTE]
|
||||||
|
> A poll is up to decide the future of yt-dlp-web-ui frontend! If you're interested you can take part.
|
||||||
|
> https://github.com/marcopiovanello/yt-dlp-web-ui/discussions/223
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -6,14 +10,14 @@ High performance extendeable web ui and RPC server for yt-dlp with low impact on
|
|||||||
|
|
||||||
Created for the only purpose of *fetching* videos from my server/nas and monitor upcoming livestreams.
|
Created for the only purpose of *fetching* videos from my server/nas and monitor upcoming livestreams.
|
||||||
|
|
||||||
**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/marcopiovanello/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui)**.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker pull marcobaobao/yt-dlp-webui
|
docker pull marcobaobao/yt-dlp-webui
|
||||||
```
|
```
|
||||||
```sh
|
```sh
|
||||||
# latest dev
|
# latest dev
|
||||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
docker pull ghcr.io/marcopiovanello/yt-dlp-web-ui:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Donate to yt-dlp-webui development
|
## Donate to yt-dlp-webui development
|
||||||
@@ -21,13 +25,18 @@ docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
|||||||
|
|
||||||
*Keeps the project alive!* 😃
|
*Keeps the project alive!* 😃
|
||||||
|
|
||||||
|
## Community stuff
|
||||||
|
Feel free to join :)
|
||||||
|
|
||||||
|
[Discord](https://discord.gg/GZAX5FfGzE)
|
||||||
|
|
||||||
## Some screeshots
|
## Some screeshots
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Video showcase
|
## Video showcase
|
||||||
[app.webm](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964)
|
[app.webm](https://github.com/marcopiovanello/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964)
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
@@ -48,7 +57,7 @@ This feature is disabled by default as this intended to be used to retrieve the
|
|||||||
|
|
||||||
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
|
## [Docker](https://github.com/marcopiovanello/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) run
|
||||||
```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
|
||||||
@@ -106,7 +115,17 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
|
### ⚡ One-Click Deploy
|
||||||
|
|
||||||
|
| Cloud Provider | Deploy Button |
|
||||||
|
|----------------|---------------|
|
||||||
|
| AWS | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=aws&language=cfn"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/aws.svg" height="38"></a> |
|
||||||
|
| DigitalOcean | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=do&language=dop"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/do.svg" height="38"></a> |
|
||||||
|
| Render | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=rnd&language=rnd"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/rnd.svg" height="38"></a> |
|
||||||
|
|
||||||
|
<sub>Generated by <a href="https://deploystack.io/c/marcopiovanello-yt-dlp-web-ui" target="_blank">DeployStack.io</a></sub>
|
||||||
|
|
||||||
|
## [Prebuilt binaries](https://github.com/marcopiovanello/yt-dlp-web-ui/releases) installation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# download the latest release from the releases page
|
# download the latest release from the releases page
|
||||||
@@ -151,6 +170,8 @@ Usage yt-dlp-webui:
|
|||||||
session file path (default ".")
|
session file path (default ".")
|
||||||
-user string
|
-user string
|
||||||
Username required for auth
|
Username required for auth
|
||||||
|
-web string
|
||||||
|
frontend web resources path
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config file
|
### Config file
|
||||||
@@ -180,7 +201,7 @@ password: my_random_secret
|
|||||||
queue_size: 4 # min. 2
|
queue_size: 4 # min. 2
|
||||||
|
|
||||||
# [optional] Full path to the yt-dlp (default: "yt-dlp")
|
# [optional] Full path to the yt-dlp (default: "yt-dlp")
|
||||||
downloaderPath: /usr/local/bin/yt-dlp
|
#downloaderPath: /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
# [optional] Enable file based logging with rotation (default: false)
|
# [optional] Enable file based logging with rotation (default: false)
|
||||||
#enable_file_logging: false
|
#enable_file_logging: false
|
||||||
@@ -193,6 +214,9 @@ downloaderPath: /usr/local/bin/yt-dlp
|
|||||||
|
|
||||||
# [optional] Path where the sqlite database will be created/opened (default: "./local.db")
|
# [optional] Path where the sqlite database will be created/opened (default: "./local.db")
|
||||||
#local_database_path
|
#local_database_path
|
||||||
|
|
||||||
|
# [optional] Path where a custom frontend will be loaded (instead of the embedded one)
|
||||||
|
#frontend_path: ./web/solid-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
### Systemd integration
|
### Systemd integration
|
||||||
@@ -258,6 +282,22 @@ It is **planned** to also expose a **gRPC** server.
|
|||||||
|
|
||||||
For more information open an issue on GitHub and I will provide more info ASAP.
|
For more information open an issue on GitHub and I will provide more info ASAP.
|
||||||
|
|
||||||
|
## Custom frontend
|
||||||
|
To load a custom frontend you need to specify its path either in the config file ([see config file](#config-file)) or via flags.
|
||||||
|
|
||||||
|
The frontend needs to follow this structure:
|
||||||
|
```
|
||||||
|
path/to/my/frontend
|
||||||
|
├── assets
|
||||||
|
│ ├── js-chunk-1.js (example)
|
||||||
|
│ ├── js-chunk-2.js (example)
|
||||||
|
│ ├── style.css (example)
|
||||||
|
└── index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
`assets` is where the resources will be loaded.
|
||||||
|
`index.html` is the entrypoint.
|
||||||
|
|
||||||
## Nix
|
## Nix
|
||||||
This repo adds support for Nix(OS) in various ways through a `flake-parts` flake.
|
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/).
|
For more info, please refer to the [official documentation](https://nixos.org/learn/).
|
||||||
|
|||||||
27
examples/docker-compose-nginx/app.conf
Normal file
27
examples/docker-compose-nginx/app.conf
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app:3033;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
client_max_body_size 20000m;
|
||||||
|
proxy_connect_timeout 5000;
|
||||||
|
proxy_send_timeout 5000;
|
||||||
|
proxy_read_timeout 5000;
|
||||||
|
send_timeout 5000;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
examples/docker-compose-nginx/docker-compose.yml
Normal file
15
examples/docker-compose-nginx/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: marcobaobao/yt-dlp-webui
|
||||||
|
volumes:
|
||||||
|
- ./downloads:/downloads
|
||||||
|
restart: unless-stopped
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./app.conf:/etc/nginx/conf.d/app.conf
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
@@ -1,39 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "yt-dlp-webui",
|
"name": "yt-dlp-webui",
|
||||||
"version": "3.2.2",
|
"version": "3.2.6",
|
||||||
"description": "Frontend compontent of yt-dlp-webui",
|
"description": "Frontend compontent of yt-dlp-webui",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"author": "marcopeocchi",
|
"type": "module",
|
||||||
|
"author": "marcopiovanello",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.11.5",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@fontsource/roboto": "^5.0.13",
|
"@fontsource/roboto": "^5.0.13",
|
||||||
"@fontsource/roboto-mono": "^5.0.18",
|
"@fontsource/roboto-mono": "^5.0.18",
|
||||||
"@mui/icons-material": "^5.15.16",
|
"@mui/icons-material": "^6.2.0",
|
||||||
"@mui/material": "^5.15.16",
|
"@mui/material": "^6.2.0",
|
||||||
"fp-ts": "^2.16.5",
|
"fp-ts": "^2.16.5",
|
||||||
"react": "^18.3.1",
|
"jotai": "^2.10.3",
|
||||||
"react-dom": "^18.3.1",
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"react-virtuoso": "^4.7.11",
|
"react-virtuoso": "^4.7.11",
|
||||||
"jotai": "^2.10.2",
|
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"@types/node": "^20.14.2",
|
"@types/node": "^20.14.2",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^19.0.1",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@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.7.2",
|
||||||
"million": "^3.1.11",
|
"typescript": "^5.7.2",
|
||||||
"typescript": "^5.4.5",
|
"vite": "^6.0.3"
|
||||||
"vite": "^5.2.11"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1668
frontend/pnpm-lock.yaml
generated
1668
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
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 CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||||
import Dashboard from '@mui/icons-material/Dashboard'
|
import Dashboard from '@mui/icons-material/Dashboard'
|
||||||
import LiveTvIcon from '@mui/icons-material/LiveTv'
|
import LiveTvIcon from '@mui/icons-material/LiveTv'
|
||||||
import Menu from '@mui/icons-material/Menu'
|
import Menu from '@mui/icons-material/Menu'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||||
|
import UpdateIcon from '@mui/icons-material/Update'
|
||||||
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,6 +18,7 @@ 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 { useAtomValue } from 'jotai'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link, Outlet } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
import { settingsState } from './atoms/settings'
|
import { settingsState } from './atoms/settings'
|
||||||
@@ -26,9 +28,10 @@ import Footer from './components/Footer'
|
|||||||
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 TwitchIcon from './components/TwitchIcon'
|
||||||
import { useI18n } from './hooks/useI18n'
|
import { useI18n } from './hooks/useI18n'
|
||||||
import Toaster from './providers/ToasterProvider'
|
import Toaster from './providers/ToasterProvider'
|
||||||
import { useAtomValue } from 'jotai'
|
import { getAccentValue } from './utils'
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -40,11 +43,14 @@ export default function Layout() {
|
|||||||
createTheme({
|
createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: settings.theme,
|
mode: settings.theme,
|
||||||
|
primary: {
|
||||||
|
main: getAccentValue(settings.accent, settings.theme)
|
||||||
|
},
|
||||||
background: {
|
background: {
|
||||||
default: settings.theme === 'light' ? grey[50] : '#121212'
|
default: settings.theme === 'light' ? grey[50] : '#121212'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}), [settings.theme]
|
}), [settings.theme, settings.accent]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleDrawer = () => setOpen(state => !state)
|
const toggleDrawer = () => setOpen(state => !state)
|
||||||
@@ -53,6 +59,7 @@ export default function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
|
<title>{settings.appTitle}</title>
|
||||||
<SocketSubscriber />
|
<SocketSubscriber />
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
@@ -109,7 +116,7 @@ export default function Layout() {
|
|||||||
<ListItemText primary={i18n.t('homeButtonLabel')} />
|
<ListItemText primary={i18n.t('homeButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={'/archive'} style={
|
{/* <Link to={'/archive'} style={
|
||||||
{
|
{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
@@ -121,6 +128,45 @@ export default function Layout() {
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
</Link> */}
|
||||||
|
<Link to={'/filebrowser'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<CloudDownloadIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
|
<Link to={'/subscriptions'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<UpdateIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={i18n.t('subscriptionsButtonLabel')} />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
|
<Link to={'/twitch'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<TwitchIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={"Twitch"} />
|
||||||
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={'/monitor'} style={
|
<Link to={'/monitor'} style={
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,791 +1,22 @@
|
|||||||
---
|
---
|
||||||
|
# Check the i18n src/assets/i18n folder.
|
||||||
|
#
|
||||||
|
# This file maps the language name to its translations file
|
||||||
|
# english -> /src/assets/i18n/en_US.yaml
|
||||||
|
|
||||||
languages:
|
languages:
|
||||||
english:
|
catalan: ca.yaml
|
||||||
urlInput: Video URL (one per line)
|
german: de.yaml
|
||||||
statusTitle: Status
|
english: en_US.yaml
|
||||||
statusReady: Ready
|
spanish: es.yaml
|
||||||
selectFormatButton: Select format
|
french: fr.yaml
|
||||||
startButton: Start
|
italian: it_IT.yaml
|
||||||
abortAllButton: Abort All
|
japanese: ja.yaml
|
||||||
updateBinButton: Update yt-dlp binary
|
korean: ko.yaml
|
||||||
darkThemeButton: Dark theme
|
polish: pl.yaml
|
||||||
lightThemeButton: Light theme
|
portuguese-br: pt_BR.yaml
|
||||||
settingsAnchor: Settings
|
russian: ru.yaml
|
||||||
serverAddressTitle: Server address
|
swedish: sv.yaml
|
||||||
serverPortTitle: Port
|
ukrainian: uk.yaml
|
||||||
extractAudioCheckbox: Extract audio
|
chinese: zh_CN.yaml
|
||||||
noMTimeCheckbox: Don't set file modification time
|
hungarian: hu.yaml
|
||||||
bgReminder: Once you close this page the download will continue in the background.
|
|
||||||
toastConnected: 'Connected to '
|
|
||||||
toastUpdated: Updated yt-dlp binary!
|
|
||||||
formatSelectionEnabler: Enable video/audio formats selection
|
|
||||||
themeSelect: 'Theme'
|
|
||||||
languageSelect: 'Language'
|
|
||||||
overridesAnchor: Overrides
|
|
||||||
pathOverrideOption: Enable output path overriding
|
|
||||||
filenameOverrideOption: Enable output file name overriding
|
|
||||||
customFilename: Custom filename (leave blank to use default)
|
|
||||||
customPath: Custom path
|
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
|
|
||||||
customArgsInput: Custom yt-dlp arguments
|
|
||||||
rpcConnErr: Error while conencting to RPC server
|
|
||||||
splashText: No active downloads
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: Copied URL to clipboard
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
|
|
||||||
restartAppMessage: Needs a page reload to take effect
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
|
||||||
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
rpcPollingTimeTitle: RPC polling time
|
|
||||||
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
|
||||||
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:
|
|
||||||
urlInput: Video URL
|
|
||||||
statusTitle: Status
|
|
||||||
statusReady: Bereit
|
|
||||||
selectFormatButton: Format auswählen
|
|
||||||
startButton: Start
|
|
||||||
abortAllButton: Alle Abbrechen
|
|
||||||
updateBinButton: yt-dlp Binärdatei aktualisieren
|
|
||||||
darkThemeButton: Dunkel Modus
|
|
||||||
lightThemeButton: Hell Modus
|
|
||||||
settingsAnchor: Einstellungen
|
|
||||||
serverAddressTitle: Server Adresse
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Audio extrahieren
|
|
||||||
noMTimeCheckbox: Datei-Änderungszeitpunkt nicht festlegen
|
|
||||||
bgReminder: Sobald Sie diese Seite schließen, wird der Download im Hintergrund fortgesetzt.
|
|
||||||
toastConnected: 'Verbunden mit '
|
|
||||||
toastUpdated: yt-dlp Binärdatei aktualisiert!
|
|
||||||
formatSelectionEnabler: Video/Audio Format auswählbar
|
|
||||||
themeSelect: 'Modus'
|
|
||||||
languageSelect: 'Sprache'
|
|
||||||
overridesAnchor: Überschreibungen
|
|
||||||
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
|
|
||||||
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
|
|
||||||
customFilename: Custom filename (leave blank to use default)
|
|
||||||
customPath: Benutzerdefinierter Pfad
|
|
||||||
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
|
|
||||||
customArgsInput: Benutzerdefinierte yt-dlp Argumente
|
|
||||||
rpcConnErr: Fehler beim Verbinden mit RPC Server
|
|
||||||
splashText: Keine aktiven Downloads
|
|
||||||
archiveTitle: Archiv
|
|
||||||
clipboardAction: URL in Zwischenablage kopiert
|
|
||||||
playlistCheckbox: Playlist herunterladen (es wird einige Zeit dauern, nach dem Absenden können Sie dieses Fenster schließen)
|
|
||||||
restartAppMessage: Erfordert ein Neuladen der Seite, um wirksam zu werden
|
|
||||||
servedFromReverseProxyCheckbox: Ist hinter einem Reverse Proxy Unterordner
|
|
||||||
newDownloadButton: Neuer Download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archiv
|
|
||||||
settingsButtonLabel: Einstellungen
|
|
||||||
rpcAuthenticationLabel: RPC Authentifizierung
|
|
||||||
themeTogglerLabel: Modus Umschalter
|
|
||||||
loadingLabel: Lädt...
|
|
||||||
appTitle: App Titel
|
|
||||||
savedTemplates: Gespeicherte Vorlage
|
|
||||||
templatesEditor: Vorlagen Bearbeiter
|
|
||||||
templatesEditorNameLabel: Vorlagen Name
|
|
||||||
templatesEditorContentLabel: Vorlagen Inhalt
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
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:
|
|
||||||
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
|
||||||
statusTitle: Statut
|
|
||||||
statusReady: Prêt
|
|
||||||
selectFormatButton: Sélectionner le format
|
|
||||||
startButton: Démarrer
|
|
||||||
abortAllButton: Tout arrêter
|
|
||||||
updateBinButton: Mettre à jour l'exécutable yt-dlp
|
|
||||||
darkThemeButton: Thème sombre
|
|
||||||
lightThemeButton: Thème clair
|
|
||||||
settingsAnchor: Paramètres
|
|
||||||
serverAddressTitle: Adresse du serveur
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Extraire l'audio
|
|
||||||
noMTimeCheckbox: Ne pas définir le temps de modification du fichier
|
|
||||||
bgReminder: Une fois que vous aurez fermé cette page, le téléchargement continuera en arrière-plan.
|
|
||||||
toastConnected: 'Connecté à '
|
|
||||||
toastUpdated: L'exécutable yt-dlp a été mis à jour !
|
|
||||||
formatSelectionEnabler: Activer la sélection des formats vidéo/audio
|
|
||||||
themeSelect: 'Thème'
|
|
||||||
languageSelect: 'Langue'
|
|
||||||
overridesAnchor: Remplacer
|
|
||||||
pathOverrideOption: Activer le remplacement du chemin de sortie
|
|
||||||
filenameOverrideOption: Activer le remplacement du nom du fichier de sortie
|
|
||||||
customFilename: Nom de fichier personnalisé (laisser vide pour utiliser le nom par défaut)
|
|
||||||
customPath: Chemin personnalisé
|
|
||||||
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
|
|
||||||
customArgsInput: Arguments yt-dlp personnalisés
|
|
||||||
rpcConnErr: Erreur lors de la connexion au serveur RPC
|
|
||||||
splashText: Aucun téléchargement actif
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: URL copiée dans le presse-papiers
|
|
||||||
playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée)
|
|
||||||
restartAppMessage: Nécessite un rechargement de la page pour prendre effet
|
|
||||||
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
|
|
||||||
notConnectedText: not connected
|
|
||||||
settingsLabel: Settings
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: Nom de l'application
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
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:
|
|
||||||
urlInput: URL Video (uno per linea)
|
|
||||||
statusTitle: Stato
|
|
||||||
startButton: Inizia
|
|
||||||
statusReady: Pronto
|
|
||||||
abortAllButton: Termina tutto
|
|
||||||
updateBinButton: Aggiorna yt-dlp
|
|
||||||
darkThemeButton: Tema scuro
|
|
||||||
lightThemeButton: Tema chiaro
|
|
||||||
settingsAnchor: Impostazioni
|
|
||||||
serverAddressTitle: Indirizzo server
|
|
||||||
serverPortTitle: Porta
|
|
||||||
extractAudioCheckbox: Estrai l'audio
|
|
||||||
noMTimeCheckbox: Non impostare la proprietà "Data ultima modifica"
|
|
||||||
bgReminder: Chiusa questa UI il download continuerà in background.
|
|
||||||
toastConnected: 'Connesso a '
|
|
||||||
toastUpdated: yt-dlp aggiornato con successo!
|
|
||||||
formatSelectionEnabler: Abilita la selezione dei formati audio/video
|
|
||||||
themeSelect: 'Tema'
|
|
||||||
languageSelect: 'Lingua'
|
|
||||||
overridesAnchor: Sovrascritture
|
|
||||||
pathOverrideOption: Abilita sovrascrittura percorso di output
|
|
||||||
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
|
||||||
customFilename: Custom filename (leave blank to use default)
|
|
||||||
customPath: Custom path
|
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
|
||||||
customArgsInput: Custom yt-dlp arguments
|
|
||||||
rpcConnErr: Error nella connessione al server RPC
|
|
||||||
splashText: Nessun download attivo
|
|
||||||
archiveTitle: Archivio
|
|
||||||
clipboardAction: URL copiato negli appunti
|
|
||||||
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
|
|
||||||
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
|
||||||
newDownloadButton: Nuovo download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: Titolo applicazione
|
|
||||||
savedTemplates: Template salvati
|
|
||||||
templatesEditor: Editor template
|
|
||||||
templatesEditorNameLabel: Nome template
|
|
||||||
templatesEditorContentLabel: Contentunto template
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
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:
|
|
||||||
urlInput: 视频 URL
|
|
||||||
statusTitle: 状态
|
|
||||||
statusReady: 就绪
|
|
||||||
selectFormatButton: 选择格式
|
|
||||||
startButton: 开始
|
|
||||||
abortAllButton: 全部中止
|
|
||||||
updateBinButton: 更新 yt-dlp 可执行文件
|
|
||||||
darkThemeButton: 黑暗主题
|
|
||||||
lightThemeButton: 明亮主题
|
|
||||||
settingsAnchor: 设置
|
|
||||||
serverAddressTitle: 服务器地址
|
|
||||||
serverPortTitle: 端口
|
|
||||||
extractAudioCheckbox: 提取音频
|
|
||||||
noMTimeCheckbox: 不设置文件修改时间
|
|
||||||
bgReminder: 关闭页面后,下载会继续在后台运行。
|
|
||||||
toastConnected: '已连接到 '
|
|
||||||
toastUpdated: 已更新 yt-dlp 可执行文件!
|
|
||||||
formatSelectionEnabler: 启用视频/音频格式选择
|
|
||||||
themeSelect: '主题'
|
|
||||||
languageSelect: '语言'
|
|
||||||
overridesAnchor: 覆盖
|
|
||||||
pathOverrideOption: 启用输出路径覆盖
|
|
||||||
filenameOverrideOption: 启用输出文件名覆盖
|
|
||||||
customFilename: 自定义文件名(留空使用默认值)
|
|
||||||
customPath: 自定义路径
|
|
||||||
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
|
|
||||||
customArgsInput: 自定义 yt-dlp 参数
|
|
||||||
rpcConnErr: 连接 RPC 服务器发生错误
|
|
||||||
splashText: 没有正在进行的下载
|
|
||||||
archiveTitle: 归档
|
|
||||||
clipboardAction: 复制 URL 到剪贴板
|
|
||||||
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
|
|
||||||
restartAppMessage: 需要刷新页面才能生效
|
|
||||||
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
|
|
||||||
newDownloadButton: 新下载
|
|
||||||
homeButtonLabel: 主页
|
|
||||||
archiveButtonLabel: 归档
|
|
||||||
settingsButtonLabel: 设置
|
|
||||||
rpcAuthenticationLabel: RPC 身份验证
|
|
||||||
themeTogglerLabel: 主题切换
|
|
||||||
loadingLabel: 正在加载…
|
|
||||||
appTitle: App 标题
|
|
||||||
savedTemplates: 保存模板
|
|
||||||
templatesEditor: 模板编辑器
|
|
||||||
templatesEditorNameLabel: 模板名称
|
|
||||||
templatesEditorContentLabel: 模板内容
|
|
||||||
logsTitle: '日志'
|
|
||||||
awaitingLogs: '正在等待日志…'
|
|
||||||
bulkDownload: '下载 zip 压缩包中的文件'
|
|
||||||
livestreamURLInput: 直播 URL
|
|
||||||
livestreamStatusWaiting: 等待直播开始
|
|
||||||
livestreamStatusDownloading: 下载中
|
|
||||||
livestreamStatusCompleted: 已完成
|
|
||||||
livestreamStatusErrored: 发生错误
|
|
||||||
livestreamStatusUnknown: 未知
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
本功能将会监控即将开始的直播流,每个进程都会传入参数:--wait-for-video 10 (重试间隔10秒)
|
|
||||||
如果直播已经开始,那么依然可以下载,但是不会记录下载进度。
|
|
||||||
直播开始后,将会转移到下载页面
|
|
||||||
livestreamExperimentalWarning: 实验性功能,可能存在未知Bug,请谨慎使用
|
|
||||||
spanish:
|
|
||||||
urlInput: URL de YouTube u otro servicio compatible
|
|
||||||
statusTitle: Estado
|
|
||||||
startButton: Iniciar
|
|
||||||
statusReady: Listo
|
|
||||||
abortAllButton: Cancelar Todo
|
|
||||||
updateBinButton: Actualizar el binario yt-dlp
|
|
||||||
darkThemeButton: Tema oscuro
|
|
||||||
lightThemeButton: Tema claro
|
|
||||||
settingsAnchor: Ajustes
|
|
||||||
serverAddressTitle: Dirección del servidor
|
|
||||||
serverPortTitle: Puerto
|
|
||||||
extractAudioCheckbox: Extraer audio
|
|
||||||
noMTimeCheckbox: No guardar el tiempo de modificación del archivo
|
|
||||||
bgReminder: Si cierras esta página, la descarga continuará en segundo plano.
|
|
||||||
toastConnected: 'Conectado a'
|
|
||||||
toastUpdated: ¡El binario yt-dlp está actualizado!
|
|
||||||
formatSelectionEnabler: Habilitar la selección de formatos de video/audio
|
|
||||||
themeSelect: 'Tema'
|
|
||||||
languageSelect: 'Idiomas'
|
|
||||||
overridesAnchor: Anulaciones
|
|
||||||
pathOverrideOption: Sobreescribir en la ruta de salida
|
|
||||||
filenameOverrideOption: Sobreescribir el nombre del fichero
|
|
||||||
customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
|
|
||||||
customPath: Ruta personalizada
|
|
||||||
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
|
||||||
customArgsInput: Argumentos yt-dlp personalizados
|
|
||||||
rpcConnErr: Error al conectarse al servidor RPC
|
|
||||||
splashText: No active downloads
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: Copied URL to clipboard
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
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:
|
|
||||||
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
|
||||||
statusTitle: Статус
|
|
||||||
startButton: Начать
|
|
||||||
statusReady: Готово
|
|
||||||
abortAllButton: Прервать все
|
|
||||||
updateBinButton: Обновить бинарный файл yt-dlp
|
|
||||||
darkThemeButton: Темная тема
|
|
||||||
lightThemeButton: Светлая тема
|
|
||||||
settingsAnchor: Настройки
|
|
||||||
serverAddressTitle: Адрес сервера
|
|
||||||
serverPortTitle: Порт
|
|
||||||
extractAudioCheckbox: Извлечь аудио
|
|
||||||
noMTimeCheckbox: Не устанавливать время модификации файла
|
|
||||||
bgReminder: Как только вы закроете эту страницу, загрузка продолжится в фоновом режиме.
|
|
||||||
toastConnected: 'Подключен к '
|
|
||||||
toastUpdated: Бинарный файл yt-dlp обновлен!
|
|
||||||
formatSelectionEnabler: Активировать выбор видео/аудио форматов
|
|
||||||
themeSelect: 'Тема'
|
|
||||||
languageSelect: 'Язык'
|
|
||||||
overridesAnchor: Переопределить
|
|
||||||
pathOverrideOption: Активировать переопределение выходного пути
|
|
||||||
filenameOverrideOption: Активировать переопределение имени выходного файла
|
|
||||||
customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию)
|
|
||||||
customPath: Задать путь
|
|
||||||
customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность)
|
|
||||||
customArgsInput: Пользовательские аргументы yt-dlp
|
|
||||||
rpcConnErr: Ошибка при подключении к серверу RPC
|
|
||||||
splashText: Нет активных загрузок
|
|
||||||
archiveTitle: Архив
|
|
||||||
clipboardAction: URL скопирован в буфер обмена
|
|
||||||
playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно
|
|
||||||
servedFromReverseProxyCheckbox: Находится за обратным прокси
|
|
||||||
newDownloadButton: Новая загрузка
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Архив
|
|
||||||
settingsButtonLabel: Настройки
|
|
||||||
rpcAuthenticationLabel: RPC-аутентификация
|
|
||||||
themeTogglerLabel: Переключить тему
|
|
||||||
loadingLabel: Загрузка...
|
|
||||||
appTitle: Название приложения
|
|
||||||
savedTemplates: Сохраненные шаблоны
|
|
||||||
templatesEditor: Редактор шаблонов
|
|
||||||
templatesEditorNameLabel: Имя шаблона
|
|
||||||
templatesEditorContentLabel: Содержание шаблона
|
|
||||||
logsTitle: 'Логи'
|
|
||||||
awaitingLogs: 'Ожидание логов...'
|
|
||||||
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:
|
|
||||||
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
|
||||||
statusTitle: 상태
|
|
||||||
startButton: 시작
|
|
||||||
statusReady: 준비됨
|
|
||||||
abortAllButton: 모두 중단
|
|
||||||
updateBinButton: yt-dlp 바이너리 업데이트
|
|
||||||
darkThemeButton: 다크 모드
|
|
||||||
lightThemeButton: 라이트 모드
|
|
||||||
settingsAnchor: 설정
|
|
||||||
serverAddressTitle: 서버 주소
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: 오디오 추출
|
|
||||||
noMTimeCheckbox: 파일 수정 시간을 설정하지 않음
|
|
||||||
bgReminder: 이 페이지를 닫아도 백그라운드에서 다운로드가 계속됩니다
|
|
||||||
toastConnected: '다음으로 연결됨 '
|
|
||||||
toastUpdated: yt-dlp 바이너리를 업데이트 했습니다
|
|
||||||
formatSelectionEnabler: 비디오/오디오 포멧 옵션 표시
|
|
||||||
themeSelect: 'Theme'
|
|
||||||
languageSelect: 'Language'
|
|
||||||
overridesAnchor: Overrides
|
|
||||||
pathOverrideOption: Enable output path overriding
|
|
||||||
filenameOverrideOption: Enable output file name overriding
|
|
||||||
customFilename: Custom filename (leave blank to use default)
|
|
||||||
customPath: Custom path
|
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
|
||||||
customArgsInput: Custom yt-dlp arguments
|
|
||||||
rpcConnErr: Error while conencting to RPC server
|
|
||||||
splashText: No active downloads
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: Copied URL to clipboard
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
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:
|
|
||||||
urlInput: YouTubeまたはサポート済み動画のURL
|
|
||||||
statusTitle: 状態
|
|
||||||
statusReady: 準備
|
|
||||||
selectFormatButton: フォーマット選択
|
|
||||||
startButton: 開始
|
|
||||||
abortAllButton: すべて中止
|
|
||||||
updateBinButton: yt-dlp更新
|
|
||||||
darkThemeButton: 黒テーマ
|
|
||||||
lightThemeButton: 白テーマ
|
|
||||||
settingsAnchor: 設定
|
|
||||||
serverAddressTitle: サーバーアドレス
|
|
||||||
serverPortTitle: ポート番号
|
|
||||||
extractAudioCheckbox: 音質
|
|
||||||
noMTimeCheckbox: ファイル時間の修正をしない
|
|
||||||
bgReminder: このページを閉じてもバックグラウンドでダウンロードを続けます
|
|
||||||
toastConnected: '接続中 '
|
|
||||||
toastUpdated: yt-dlpを更新しました!
|
|
||||||
formatSelectionEnabler: 選択可能な動画/音源
|
|
||||||
themeSelect: 'テーマ'
|
|
||||||
languageSelect: '言語'
|
|
||||||
overridesAnchor: 上書き
|
|
||||||
pathOverrideOption: 保存するディレクトリ
|
|
||||||
filenameOverrideOption: ファイル名の上書き
|
|
||||||
customFilename: (空白の場合は元のファイル名)
|
|
||||||
customPath: 保存先
|
|
||||||
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
|
||||||
customArgsInput: yt-dlpのオプション
|
|
||||||
rpcConnErr: RPCサーバーへの接続中にエラーが発生しました
|
|
||||||
splashText: アクティブなダウンロードはありません
|
|
||||||
archiveTitle: アーカイブ
|
|
||||||
clipboardAction: URLをクリップボードにコピーしました
|
|
||||||
playlistCheckbox: プレイリストをダウンロード (これには時間がかかりますが、処理中はウィンドウを閉じることができます)
|
|
||||||
servedFromReverseProxyCheckbox: リバースプロキシのサブフォルダにあります
|
|
||||||
newDownloadButton: 新しくダウンロード
|
|
||||||
homeButtonLabel: ホーム
|
|
||||||
archiveButtonLabel: アーカイブ
|
|
||||||
settingsButtonLabel: 設定
|
|
||||||
rpcAuthenticationLabel: RPC認証
|
|
||||||
themeTogglerLabel: テーマ切り替え
|
|
||||||
loadingLabel: 読み込み中...
|
|
||||||
appTitle: アプリタイトル
|
|
||||||
savedTemplates: 保存したテンプレート
|
|
||||||
templatesEditor: テンプレートエディター
|
|
||||||
templatesEditorNameLabel: テンプレート名
|
|
||||||
templatesEditorContentLabel: テンプレート内容
|
|
||||||
logsTitle: 'ログ'
|
|
||||||
awaitingLogs: 'ログを待機中...'
|
|
||||||
bulkDownload: 'ダウンロードしたファイルをZIPで保存'
|
|
||||||
livestreamURLInput: ライブストリームURL
|
|
||||||
livestreamStatusWaiting: 開始を待っています
|
|
||||||
livestreamStatusDownloading: ダウンロード中
|
|
||||||
livestreamStatusCompleted: 完了
|
|
||||||
livestreamStatusErrored: エラー
|
|
||||||
livestreamStatusUnknown: 不明
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
まだ開始されていないライブストリームを監視します。各プロセスは、--wait-for-video 10 で実行されます。
|
|
||||||
すでに開始されているライブストリームが提供された場合、ダウンロードは継続されますが進行状況は追跡されません。
|
|
||||||
ライブストリームが開始されると、ダウンロードページに移動されます。
|
|
||||||
livestreamExperimentalWarning: この機能は実験的なものです。何かが壊れるかもしれません!
|
|
||||||
catalan:
|
|
||||||
urlInput: URL de YouTube o d'un altre servei compatible
|
|
||||||
statusTitle: Estat
|
|
||||||
startButton: Iniciar
|
|
||||||
statusReady: Llest
|
|
||||||
abortAllButton: Cancel·lar Tot
|
|
||||||
updateBinButton: Actualitzar el binari yt-dlp
|
|
||||||
darkThemeButton: Tema fosc
|
|
||||||
lightThemeButton: Tema clar
|
|
||||||
settingsAnchor: Configuració
|
|
||||||
serverAddressTitle: Direcció del servidor
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Extreure àudio
|
|
||||||
noMTimeCheckbox: No guardar el temps de modificació de l'arxiu
|
|
||||||
bgReminder: Si tanques aquesta pàgina, la descàrrega continuarà en segon pla.
|
|
||||||
toastConnected: 'Connectat a'
|
|
||||||
toastUpdated: El binari yt-dlp està actualitzat!
|
|
||||||
formatSelectionEnabler: Habilitar la selecció de formats de vídeo/àudio
|
|
||||||
themeSelect: 'Tema'
|
|
||||||
languageSelect: 'Idiomes'
|
|
||||||
overridesAnchor: Anul·lacions
|
|
||||||
pathOverrideOption: Sobreescriure en la ruta de sortida
|
|
||||||
filenameOverrideOption: Sobreescriure el nom del fitxer
|
|
||||||
customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat)
|
|
||||||
customPath: Ruta personalitzada
|
|
||||||
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
|
|
||||||
customArgsInput: Arguments yt-dlp personalitzats
|
|
||||||
rpcConnErr: Error en connectar-se al servidor RPC
|
|
||||||
splashText: No active downloads
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: Copied URL to clipboard
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
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:
|
|
||||||
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
|
||||||
statusTitle: Статус
|
|
||||||
startButton: Почати
|
|
||||||
statusReady: Готово
|
|
||||||
abortAllButton: Перервати все
|
|
||||||
updateBinButton: Оновити бінарний файл yt-dlp
|
|
||||||
darkThemeButton: Темна тема
|
|
||||||
lightThemeButton: Світла тема
|
|
||||||
settingsAnchor: Налаштування
|
|
||||||
serverAddressTitle: Адреса сервера
|
|
||||||
serverPortTitle: Порт
|
|
||||||
extractAudioCheckbox: Витягти аудіо
|
|
||||||
noMTimeCheckbox: Не встановлювати час модифікації файлу
|
|
||||||
bgReminder: Як тільки ви закриєте цю сторінку, завантаження продовжиться у фоновому режимі.
|
|
||||||
toastConnected: 'Підключений до '
|
|
||||||
toastUpdated: Бінарний файл yt-dlp оновлено!
|
|
||||||
formatSelectionEnabler: Активувати вибір відео/аудіо форматів
|
|
||||||
themeSelect: 'Тема'
|
|
||||||
languageSelect: 'Мова'
|
|
||||||
overridesAnchor: Перевизначити
|
|
||||||
pathOverrideOption: Активувати перевизначення вихідного шляху
|
|
||||||
filenameOverrideOption: Активувати перевизначення імені вихідного файлу
|
|
||||||
customFilename: Введіть ім'я файлу (залишіть порожнім, щоб використовувати значення за замовчуванням)
|
|
||||||
customPath: Задати шлях
|
|
||||||
customArgs: Включити аргументи, що настроюються yt-dlp (велика сила = велика відповідальність)
|
|
||||||
customArgsInput: Користувальницькі аргументи yt-dlp
|
|
||||||
rpcConnErr: Помилка при підключенні до сервера RPC
|
|
||||||
splashText: Немає активних завантажень
|
|
||||||
archiveTitle: Архів
|
|
||||||
clipboardAction: URL скопійовано в буфер обміну
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
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:
|
|
||||||
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
|
||||||
statusTitle: Status
|
|
||||||
startButton: Początek
|
|
||||||
statusReady: Gotowy
|
|
||||||
abortAllButton: Anuluj wszystko
|
|
||||||
updateBinButton: Zaktualizuj plik binarny yt-dlp
|
|
||||||
darkThemeButton: Ciemny motyw
|
|
||||||
lightThemeButton: Światło motyw
|
|
||||||
settingsAnchor: Ustawienia
|
|
||||||
serverAddressTitle: Adres serwera
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Wyodrębnij dźwięk
|
|
||||||
noMTimeCheckbox: Nie ustawiaj czasu modyfikacji pliku
|
|
||||||
bgReminder: Po zamknięciu tej strony pobieranie będzie kontynuowane w tle.
|
|
||||||
toastConnected: 'Połączony z '
|
|
||||||
toastUpdated: Zaktualizowano plik binarny yt-dlp!
|
|
||||||
formatSelectionEnabler: Aktywuj wybór formatów wideo/audio
|
|
||||||
themeSelect: 'Motyw'
|
|
||||||
languageSelect: 'Język'
|
|
||||||
overridesAnchor: Przedefiniuj
|
|
||||||
pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej
|
|
||||||
filenameOverrideOption: Aktywuj zastępowanie nazwy pliku źródłowego
|
|
||||||
customFilename: Wprowadź nazwę pliku (pozostaw puste, aby użyć nazwy domyślnej)
|
|
||||||
customPath: Ustaw ścieżkę
|
|
||||||
customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność)
|
|
||||||
customArgsInput: Niestandardowe argumenty yt-dlp
|
|
||||||
rpcConnErr: Wystąpił błąd podczas łączenia z serwerem RPC
|
|
||||||
splashText: Brak aktywnych pobrań
|
|
||||||
archiveTitle: Archiwum
|
|
||||||
clipboardAction: Adres URL zostanie skopiowany do schowka
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
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!
|
|
||||||
80
frontend/src/assets/i18n/ca.yaml
Normal file
80
frontend/src/assets/i18n/ca.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL de YouTube o d'un altre servei compatible
|
||||||
|
statusTitle: Estat
|
||||||
|
startButton: Iniciar
|
||||||
|
statusReady: Llest
|
||||||
|
abortAllButton: Cancel·lar Tot
|
||||||
|
updateBinButton: Actualitzar el binari yt-dlp
|
||||||
|
darkThemeButton: Tema fosc
|
||||||
|
lightThemeButton: Tema clar
|
||||||
|
settingsAnchor: Configuració
|
||||||
|
serverAddressTitle: Direcció del servidor
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Extreure àudio
|
||||||
|
noMTimeCheckbox: No guardar el temps de modificació de l'arxiu
|
||||||
|
bgReminder: Si tanques aquesta pàgina, la descàrrega continuarà en segon pla.
|
||||||
|
toastConnected: 'Connectat a'
|
||||||
|
toastUpdated: El binari yt-dlp està actualitzat!
|
||||||
|
formatSelectionEnabler: Habilitar la selecció de formats de vídeo/àudio
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Idiomes'
|
||||||
|
overridesAnchor: Anul·lacions
|
||||||
|
pathOverrideOption: Sobreescriure en la ruta de sortida
|
||||||
|
filenameOverrideOption: Sobreescriure el nom del fitxer
|
||||||
|
autoFileExtensionOption: Afegeix l'extensió de fitxer automàticament
|
||||||
|
customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat)
|
||||||
|
customPath: Ruta personalitzada
|
||||||
|
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
|
||||||
|
customArgsInput: Arguments yt-dlp personalitzats
|
||||||
|
rpcConnErr: Error en connectar-se al servidor RPC
|
||||||
|
splashText: No active downloads
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: Copied URL to clipboard
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'Ajustes generales de descarga'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/de.yaml
Normal file
82
frontend/src/assets/i18n/de.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Video URL
|
||||||
|
statusTitle: Status
|
||||||
|
statusReady: Bereit
|
||||||
|
selectFormatButton: Format auswählen
|
||||||
|
startButton: Start
|
||||||
|
abortAllButton: Alle Abbrechen
|
||||||
|
updateBinButton: yt-dlp Binärdatei aktualisieren
|
||||||
|
darkThemeButton: Dunkler Modus
|
||||||
|
lightThemeButton: Heller Modus
|
||||||
|
settingsAnchor: Einstellungen
|
||||||
|
serverAddressTitle: Adresse des Servers
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Audio extrahieren
|
||||||
|
noMTimeCheckbox: Datei-Änderungszeitpunkt nicht festlegen
|
||||||
|
bgReminder: Sobald Sie diese Seite schließen, wird der Download im Hintergrund fortgesetzt.
|
||||||
|
toastConnected: 'Verbunden mit '
|
||||||
|
toastUpdated: yt-dlp Binärdatei aktualisiert!
|
||||||
|
formatSelectionEnabler: Video/Audio Format auswählbar
|
||||||
|
themeSelect: 'Modus'
|
||||||
|
languageSelect: 'Sprache'
|
||||||
|
overridesAnchor: Überschreibungen
|
||||||
|
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
|
||||||
|
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
|
||||||
|
autoFileExtensionOption: Dateierweiterung automatisch hinzufügen
|
||||||
|
customFilename: Benutzerdefinierter Dateiname (Leer lassen um Standardwert zu nutzen)
|
||||||
|
customPath: Benutzerdefinierter Pfad
|
||||||
|
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (Auf viel Macht folgt große Verantwortung)
|
||||||
|
customArgsInput: Benutzerdefinierte yt-dlp Argumente
|
||||||
|
rpcConnErr: Fehler beim Verbinden mit RPC Server
|
||||||
|
splashText: Keine aktiven Downloads
|
||||||
|
archiveTitle: Archiv
|
||||||
|
clipboardAction: URL in Zwischenablage kopiert
|
||||||
|
playlistCheckbox: Playlist herunterladen (es wird einige Zeit dauern, nach dem Absenden können Sie dieses Fenster schließen)
|
||||||
|
restartAppMessage: Erfordert ein Neuladen der Seite, um wirksam zu werden
|
||||||
|
servedFromReverseProxyCheckbox: Ist hinter einem Reverse Proxy Unterordner
|
||||||
|
newDownloadButton: Neuer Download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archiv
|
||||||
|
settingsButtonLabel: Einstellungen
|
||||||
|
rpcAuthenticationLabel: RPC Authentifizierung
|
||||||
|
themeTogglerLabel: Modus Umschalter
|
||||||
|
loadingLabel: Lädt...
|
||||||
|
appTitle: App Titel
|
||||||
|
savedTemplates: Gespeicherte Vorlage
|
||||||
|
templatesEditor: Vorlageneditor
|
||||||
|
templatesEditorNameLabel: Vorlagen Name
|
||||||
|
templatesEditorContentLabel: Vorlagen Inhalt
|
||||||
|
logsTitle: 'Logausgabe'
|
||||||
|
awaitingLogs: 'Warte auf Log ...'
|
||||||
|
bulkDownload: 'Alles in einem ZIP-Archiv herunterladen'
|
||||||
|
rpcPollingTimeTitle: RPC-Abfragezeit
|
||||||
|
rpcPollingTimeDescription: Ein kürzerer Intervall führt zu einer höheren CPU-Auslastung (Server- und Clientseite)
|
||||||
|
templatesReloadInfo: Um eine neue Vorlage zu registrieren, muss die Seite möglicherweise neu geladen werden.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Warte auf Start
|
||||||
|
livestreamStatusDownloading: Herunterladen
|
||||||
|
livestreamStatusCompleted: Abgeschlossen
|
||||||
|
livestreamStatusErrored: Fehlerhaft
|
||||||
|
livestreamStatusUnknown: Status unbekannt
|
||||||
|
livestreamNoMonitoring: Aktuell wird kein Livestream überwacht
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
Damit wird der noch nicht gestartete Livestream überwacht. Jeder Prozess wird mit --wait-for-video 10 ausgeführt.
|
||||||
|
Wenn ein bereits gestarteter Livestream vorhanden ist, wird er zwar heruntergeladen, aber sein Fortschritt wird nicht verfolgt.
|
||||||
|
Sobald der Livestream gestartet ist, wird er auf der Download-Seite angezeigt.
|
||||||
|
livestreamExperimentalWarning: Dieses Feature ist aktuell noch experimentell, sei vorsichtig, denn es könnte sein, dass etwas nicht genau funktioniert!
|
||||||
|
accentSelect: 'Farbtöne'
|
||||||
|
urlBase: URL-Basis für Reverse-Proxy-Unterstützung (Unterverzeichnis), standardmäßig leer
|
||||||
|
generalDownloadSettings: 'Allgemeine Download Einstellungen'
|
||||||
|
deleteCookies: 'Cookies löschen'
|
||||||
|
noFilesFound: 'Keine Dateien gefunden'
|
||||||
|
tableView: 'Tabellenansicht'
|
||||||
|
deleteSelected: 'Ausgewählte löschen'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
86
frontend/src/assets/i18n/en_US.yaml
Normal file
86
frontend/src/assets/i18n/en_US.yaml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Video URL (one per line)
|
||||||
|
statusTitle: Status
|
||||||
|
statusReady: Ready
|
||||||
|
selectFormatButton: Select format
|
||||||
|
startButton: Start
|
||||||
|
abortAllButton: Abort All
|
||||||
|
updateBinButton: Update yt-dlp binary
|
||||||
|
darkThemeButton: Dark theme
|
||||||
|
lightThemeButton: Light theme
|
||||||
|
settingsAnchor: Settings
|
||||||
|
serverAddressTitle: Server address
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Extract audio
|
||||||
|
noMTimeCheckbox: Don't set file modification time
|
||||||
|
bgReminder: Once you close this page the download will continue in the background.
|
||||||
|
toastConnected: 'Connected to '
|
||||||
|
toastUpdated: Updated yt-dlp binary!
|
||||||
|
formatSelectionEnabler: Enable video/audio formats selection
|
||||||
|
themeSelect: 'Theme'
|
||||||
|
languageSelect: 'Language'
|
||||||
|
overridesAnchor: Overrides
|
||||||
|
pathOverrideOption: Enable output path overriding
|
||||||
|
filenameOverrideOption: Enable output file name overriding
|
||||||
|
autoFileExtensionOption: Automatically add file extension
|
||||||
|
customFilename: Custom filename (leave blank to use default)
|
||||||
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
|
rpcConnErr: Error while connecting to RPC server
|
||||||
|
splashText: No active downloads
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: Copied URL to clipboard
|
||||||
|
playlistCheckbox: Download playlist
|
||||||
|
restartAppMessage: Needs a page reload to take effect
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
|
clearCompletedButton: 'Clear completed'
|
||||||
|
twitchIntegrationInfo: |
|
||||||
|
To enable monitoring Twitch streams follow this wiki page.
|
||||||
|
https://github.com/marcopiovanello/yt-dlp-web-ui/wiki/Twitch-integration
|
||||||
80
frontend/src/assets/i18n/es.yaml
Normal file
80
frontend/src/assets/i18n/es.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL de YouTube u otro servicio compatible
|
||||||
|
statusTitle: Estado
|
||||||
|
startButton: Iniciar
|
||||||
|
statusReady: Listo
|
||||||
|
abortAllButton: Cancelar Todo
|
||||||
|
updateBinButton: Actualizar el binario yt-dlp
|
||||||
|
darkThemeButton: Tema oscuro
|
||||||
|
lightThemeButton: Tema claro
|
||||||
|
settingsAnchor: Ajustes
|
||||||
|
serverAddressTitle: Dirección del servidor
|
||||||
|
serverPortTitle: Puerto
|
||||||
|
extractAudioCheckbox: Extraer audio
|
||||||
|
noMTimeCheckbox: No guardar el tiempo de modificación del archivo
|
||||||
|
bgReminder: Si cierras esta página, la descarga continuará en segundo plano.
|
||||||
|
toastConnected: 'Conectado a'
|
||||||
|
toastUpdated: ¡El binario yt-dlp está actualizado!
|
||||||
|
formatSelectionEnabler: Habilitar la selección de formatos de video/audio
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Idiomas'
|
||||||
|
overridesAnchor: Anulaciones
|
||||||
|
pathOverrideOption: Sobreescribir en la ruta de salida
|
||||||
|
filenameOverrideOption: Sobreescribir el nombre del fichero
|
||||||
|
autoFileExtensionOption: Agregar extensión de archivo automáticamente
|
||||||
|
customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
|
||||||
|
customPath: Ruta personalizada
|
||||||
|
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
||||||
|
customArgsInput: Argumentos yt-dlp personalizados
|
||||||
|
rpcConnErr: Error al conectarse al servidor RPC
|
||||||
|
splashText: No active downloads
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: Copied URL to clipboard
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
84
frontend/src/assets/i18n/fr.yaml
Normal file
84
frontend/src/assets/i18n/fr.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
||||||
|
statusTitle: Statut
|
||||||
|
statusReady: Prêt
|
||||||
|
selectFormatButton: Sélectionner le format
|
||||||
|
startButton: Démarrer
|
||||||
|
abortAllButton: Tout arrêter
|
||||||
|
updateBinButton: Mettre à jour l'exécutable yt-dlp
|
||||||
|
darkThemeButton: Thème sombre
|
||||||
|
lightThemeButton: Thème clair
|
||||||
|
settingsAnchor: Paramètres
|
||||||
|
serverAddressTitle: Adresse du serveur
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Extraire l'audio
|
||||||
|
noMTimeCheckbox: Ne pas définir le temps de modification du fichier
|
||||||
|
bgReminder: Une fois que vous aurez fermé cette page, le téléchargement continuera en arrière-plan.
|
||||||
|
toastConnected: 'Connecté à '
|
||||||
|
toastUpdated: L'exécutable yt-dlp a été mis à jour !
|
||||||
|
formatSelectionEnabler: Activer la sélection des formats vidéo/audio
|
||||||
|
themeSelect: 'Thème'
|
||||||
|
languageSelect: 'Langue'
|
||||||
|
overridesAnchor: Remplacer
|
||||||
|
pathOverrideOption: Activer le remplacement du chemin de sortie
|
||||||
|
filenameOverrideOption: Activer le remplacement du nom du fichier de sortie
|
||||||
|
autoFileExtensionOption: Ajouter automatiquement l'extension de fichier
|
||||||
|
customFilename: Nom de fichier personnalisé (laisser vide pour utiliser le nom par défaut)
|
||||||
|
customPath: Chemin personnalisé
|
||||||
|
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
|
||||||
|
customArgsInput: Arguments yt-dlp personnalisés
|
||||||
|
rpcConnErr: Erreur lors de la connexion au serveur RPC
|
||||||
|
splashText: Aucun téléchargement actif
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: URL copiée dans le presse-papiers
|
||||||
|
playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée)
|
||||||
|
restartAppMessage: Nécessite un rechargement de la page pour prendre effet
|
||||||
|
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
|
||||||
|
notConnectedText: not connected
|
||||||
|
settingsLabel: Settings
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: Nom de l'application
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/hu.yaml
Normal file
82
frontend/src/assets/i18n/hu.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Video URL (soronként egy)
|
||||||
|
statusTitle: Állapot
|
||||||
|
statusReady: Előkészítve
|
||||||
|
selectFormatButton: Válassz formátumot
|
||||||
|
startButton: Indítás
|
||||||
|
abortAllButton: Összes megszakítása
|
||||||
|
updateBinButton: yt-dlp bináris frissítése
|
||||||
|
darkThemeButton: Sötét téma
|
||||||
|
lightThemeButton: Világos téma
|
||||||
|
settingsAnchor: Beállítások
|
||||||
|
serverAddressTitle: Szerver címe
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Audio konvertálása
|
||||||
|
noMTimeCheckbox: Fájl módosítás időpontja ne legyen beállítva
|
||||||
|
bgReminder: Miután a lap bezárásra kerül, a letöltés folytatódni fog a háttérben.
|
||||||
|
toastConnected: 'Kapcsolódva: '
|
||||||
|
toastUpdated: yt-dlp bináris frissítése sikeres volt!
|
||||||
|
formatSelectionEnabler: Video/audio formátum manuális kiválasztásának engedélyezése
|
||||||
|
themeSelect: 'Téma'
|
||||||
|
languageSelect: 'Nyelv'
|
||||||
|
overridesAnchor: Felülbírálások
|
||||||
|
pathOverrideOption: Letöltési útvonal felülbírálása
|
||||||
|
filenameOverrideOption: Letöltési fájlnév felülbírálása
|
||||||
|
autoFileExtensionOption: Automatikus fájlkiterjesztés
|
||||||
|
customFilename: Egyedi fájlnév (hagyd üresen, hogy a fájlnév automatikusan generálódjon)
|
||||||
|
customPath: Egyedi útvonal
|
||||||
|
customArgs: Egyedi yt-dlp argumentumok (Nagy hatalommal nagy felelősség jár.)
|
||||||
|
customArgsInput: Egyedi yt-dlp argumentumok
|
||||||
|
rpcConnErr: Hiba történt az RPC szerver történő kapcsolódáskor
|
||||||
|
splashText: Nincs aktív letöltés
|
||||||
|
archiveTitle: Archívum
|
||||||
|
clipboardAction: URL a vágólapra másolva.
|
||||||
|
playlistCheckbox: Lejátszási lista letöltése (Több időt vehet igénybe. A letöltés a háttérben történik, a böngészőablak szabadon bezárható.)
|
||||||
|
restartAppMessage: Az oldal újratöltése lehet szükséges a változtatások megjelenítéséhez.
|
||||||
|
servedFromReverseProxyCheckbox: Reverse proxy mögötti működés
|
||||||
|
urlBase: URL base, reverse proxy támogatásához (subdir), alapból üres
|
||||||
|
newDownloadButton: Új letöltés
|
||||||
|
homeButtonLabel: Kezdőlap
|
||||||
|
archiveButtonLabel: Archívum
|
||||||
|
settingsButtonLabel: Beállítások
|
||||||
|
rpcAuthenticationLabel: RPC bejelentkezés
|
||||||
|
themeTogglerLabel: Témaválasztó
|
||||||
|
loadingLabel: Betöltés...
|
||||||
|
appTitle: Alkalmazás címe
|
||||||
|
savedTemplates: Mentett sablonok
|
||||||
|
templatesEditor: Sablonszerkesztő
|
||||||
|
templatesEditorNameLabel: Sablon neve
|
||||||
|
templatesEditorContentLabel: Sablon tartalma
|
||||||
|
logsTitle: 'Naplók'
|
||||||
|
awaitingLogs: 'Napló letöltése...'
|
||||||
|
bulkDownload: 'Fájlok letöltése ZIP archívumként'
|
||||||
|
rpcPollingTimeTitle: RPC lekérdezési időköz
|
||||||
|
rpcPollingTimeDescription: Rövidebb időköz nagyobb processzor terheléssel járhat (mind szerver és böngésző oldalon is)
|
||||||
|
templatesReloadInfo: Az új sablon megjelenéséhez újra kell tölteni az oldalt.
|
||||||
|
livestreamURLInput: Élő stream URL
|
||||||
|
livestreamStatusWaiting: Várakozás a kezdésre
|
||||||
|
livestreamStatusDownloading: Letöltés
|
||||||
|
livestreamStatusCompleted: Letöltve
|
||||||
|
livestreamStatusErrored: Hiba
|
||||||
|
livestreamStatusUnknown: Ismeretlen
|
||||||
|
livestreamNoMonitoring: Nincsenek figyelt élő adások
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
Ez figyelni fog egy még el nem indított élő közvetítést. Minden folyamat a --wait-for-video 10 paraméterrel lesz végrehajtva.
|
||||||
|
Ha egy már elindított élő közvetítés van megadva, az továbbra is letöltésre kerül, de a folyamatát nem követi nyomon.
|
||||||
|
Amint elindul, az élő közvetítés átkerül a letöltések oldalra..
|
||||||
|
livestreamExperimentalWarning: Ez a funkció még kísérleti. Nem garantált a hibamentes működés.
|
||||||
|
accentSelect: 'Kiemelt szín'
|
||||||
|
generalDownloadSettings: 'Általános letöltési beállítások'
|
||||||
|
deleteCookies: Sütik törlése
|
||||||
|
noFilesFound: 'Nem található fájlok'
|
||||||
|
tableView: 'Táblázatos Nézet'
|
||||||
|
deleteSelected: 'Kiválasztottak törlése'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/it_IT.yaml
Normal file
82
frontend/src/assets/i18n/it_IT.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL Video (uno per linea)
|
||||||
|
statusTitle: Stato
|
||||||
|
statusReady: Pronto
|
||||||
|
selectFormatButton: Seziona formato
|
||||||
|
startButton: Inizia
|
||||||
|
abortAllButton: Termina tutto
|
||||||
|
updateBinButton: Aggiorna yt-dlp
|
||||||
|
darkThemeButton: Tema scuro
|
||||||
|
lightThemeButton: Tema chiaro
|
||||||
|
settingsAnchor: Impostazioni
|
||||||
|
serverAddressTitle: Indirizzo server
|
||||||
|
serverPortTitle: Porta
|
||||||
|
extractAudioCheckbox: Estrai l'audio
|
||||||
|
noMTimeCheckbox: Non impostare la proprietà "Data ultima modifica"
|
||||||
|
bgReminder: Chiusa questa UI il download continuerà in background.
|
||||||
|
toastConnected: 'Connesso a '
|
||||||
|
toastUpdated: yt-dlp aggiornato con successo!
|
||||||
|
formatSelectionEnabler: Abilita la selezione dei formati audio/video
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Lingua'
|
||||||
|
overridesAnchor: Sovrascritture
|
||||||
|
pathOverrideOption: Abilita sovrascrittura percorso di output
|
||||||
|
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
||||||
|
autoFileExtensionOption: Aggiungi estensione automaticamente
|
||||||
|
customFilename: Nome file personalizzato (lascia vuoto per utilizzare quello predefinito)
|
||||||
|
customPath: Percorso personalizzato
|
||||||
|
customArgs: Abilita argomenti yt-dlp personalizzati (grande potere = grandi responsabilità)
|
||||||
|
customArgsInput: Argomenti yt-dlp personalizzati
|
||||||
|
rpcConnErr: Errore nella connessione al server RPC
|
||||||
|
splashText: Nessun download attivo
|
||||||
|
archiveTitle: Archivio
|
||||||
|
clipboardAction: URL copiato negli appunti
|
||||||
|
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
|
||||||
|
restartAppMessage: La finestra deve essere ricaricata affinché abbia effetto
|
||||||
|
servedFromReverseProxyCheckbox: È dietro un reverse proxy
|
||||||
|
urlBase: base URL, per supporto a reverse proxy (subdir), default vuoto
|
||||||
|
newDownloadButton: Nuovo download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archivio
|
||||||
|
settingsButtonLabel: Impostazioni
|
||||||
|
rpcAuthenticationLabel: Autenticazione RPC
|
||||||
|
themeTogglerLabel: Selettore Tema
|
||||||
|
loadingLabel: Caricamento...
|
||||||
|
appTitle: Titolo applicazione
|
||||||
|
savedTemplates: Modelli salvati
|
||||||
|
templatesEditor: Editor modelli
|
||||||
|
templatesEditorNameLabel: Nome modello
|
||||||
|
templatesEditorContentLabel: Contenuto del modello
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Scaricare i file in un archivio zip'
|
||||||
|
rpcPollingTimeTitle: Intervallo di polling RPC
|
||||||
|
rpcPollingTimeDescription: Un intervallo più corto implica un maggior utilizzo di CPU (lato client e server)
|
||||||
|
templatesReloadInfo: Per registrare un nuovo modello potrebbe essere necessario ricaricare la pagina.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Attesa inizio
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completato
|
||||||
|
livestreamStatusErrored: Errore
|
||||||
|
livestreamStatusUnknown: Sconosciuto
|
||||||
|
livestreamNoMonitoring: Nessun livestream monitorato
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
Questo monitorerà il livestream ancora da avviare. Ogni processo verrà eseguito con --wait-for-video 10.
|
||||||
|
Se viene fornito un livestream già avviato, questo verrà comunque scaricato, ma il suo progresso non verrà monitorato.
|
||||||
|
Una volta avviato, il livestream verrà migrato nella pagina dei download.
|
||||||
|
livestreamExperimentalWarning: Questa funzione è ancora sperimentale. Qualcosa potrebbe rompersi!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
generalDownloadSettings: 'Impostazioni generali di download'
|
||||||
|
deleteCookies: Elimina Cookies
|
||||||
|
noFilesFound: 'Nessun file trovato'
|
||||||
|
tableView: 'Vista Tabella'
|
||||||
|
deleteSelected: 'Elimina selezionati'
|
||||||
|
subscriptionsButtonLabel: 'Abbonamenti'
|
||||||
|
subscriptionsEmptyLabel: 'Nessuna iscrizione'
|
||||||
|
subscriptionsURLInput: 'URL Canale'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Iscrive a un canale definito. Verrà scaricato solo l'ultimo video.
|
||||||
|
Il lavoro di monitoraggio sarà programmato/attivato da un'espressione cron definita (se lasciata vuota, l'impostazione predefinita è ogni 5 minuti).
|
||||||
|
cronExpressionLabel: 'Espressione Cron'
|
||||||
|
editButtonLabel: 'Modifica'
|
||||||
|
newSubscriptionButton: Nuova iscrizione
|
||||||
81
frontend/src/assets/i18n/ja.yaml
Normal file
81
frontend/src/assets/i18n/ja.yaml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: YouTubeまたはサポート済み動画のURL
|
||||||
|
statusTitle: 状態
|
||||||
|
statusReady: 準備
|
||||||
|
selectFormatButton: フォーマット選択
|
||||||
|
startButton: 開始
|
||||||
|
abortAllButton: すべて中止
|
||||||
|
updateBinButton: yt-dlp更新
|
||||||
|
darkThemeButton: 黒テーマ
|
||||||
|
lightThemeButton: 白テーマ
|
||||||
|
settingsAnchor: 設定
|
||||||
|
serverAddressTitle: サーバーアドレス
|
||||||
|
serverPortTitle: ポート番号
|
||||||
|
extractAudioCheckbox: 音質
|
||||||
|
noMTimeCheckbox: ファイル時間の修正をしない
|
||||||
|
bgReminder: このページを閉じてもバックグラウンドでダウンロードを続けます
|
||||||
|
toastConnected: '接続中 '
|
||||||
|
toastUpdated: yt-dlpを更新しました!
|
||||||
|
formatSelectionEnabler: 選択可能な動画/音源
|
||||||
|
themeSelect: 'テーマ'
|
||||||
|
languageSelect: '言語'
|
||||||
|
overridesAnchor: 上書き
|
||||||
|
pathOverrideOption: 保存するディレクトリ
|
||||||
|
filenameOverrideOption: ファイル名の上書き
|
||||||
|
autoFileExtensionOption: 自動ファイル拡張子
|
||||||
|
customFilename: (空白の場合は元のファイル名)
|
||||||
|
customPath: 保存先
|
||||||
|
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
||||||
|
customArgsInput: yt-dlpのオプション
|
||||||
|
rpcConnErr: RPCサーバーへの接続中にエラーが発生しました
|
||||||
|
splashText: アクティブなダウンロードはありません
|
||||||
|
archiveTitle: アーカイブ
|
||||||
|
clipboardAction: URLをクリップボードにコピーしました
|
||||||
|
playlistCheckbox: プレイリストをダウンロード (これには時間がかかりますが、処理中はウィンドウを閉じることができます)
|
||||||
|
servedFromReverseProxyCheckbox: リバースプロキシのサブフォルダにあります
|
||||||
|
newDownloadButton: 新しくダウンロード
|
||||||
|
homeButtonLabel: ホーム
|
||||||
|
archiveButtonLabel: アーカイブ
|
||||||
|
settingsButtonLabel: 設定
|
||||||
|
rpcAuthenticationLabel: RPC認証
|
||||||
|
themeTogglerLabel: テーマ切り替え
|
||||||
|
loadingLabel: 読み込み中...
|
||||||
|
appTitle: アプリタイトル
|
||||||
|
savedTemplates: 保存したテンプレート
|
||||||
|
templatesEditor: テンプレートエディター
|
||||||
|
templatesEditorNameLabel: テンプレート名
|
||||||
|
templatesEditorContentLabel: テンプレート内容
|
||||||
|
logsTitle: 'ログ'
|
||||||
|
awaitingLogs: 'ログを待機中...'
|
||||||
|
bulkDownload: 'ダウンロードしたファイルをZIPで保存'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: ライブストリームURL
|
||||||
|
livestreamStatusWaiting: 開始を待っています
|
||||||
|
livestreamStatusDownloading: ダウンロード中
|
||||||
|
livestreamStatusCompleted: 完了
|
||||||
|
livestreamStatusErrored: エラー
|
||||||
|
livestreamStatusUnknown: 不明
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
まだ開始されていないライブストリームを監視します。各プロセスは、--wait-for-video 10 で実行されます。
|
||||||
|
すでに開始されているライブストリームが提供された場合、ダウンロードは継続されますが進行状況は追跡されません。
|
||||||
|
ライブストリームが開始されると、ダウンロードページに移動されます。
|
||||||
|
livestreamExperimentalWarning: この機能は実験的なものです。何かが壊れるかもしれません!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
80
frontend/src/assets/i18n/ko.yaml
Normal file
80
frontend/src/assets/i18n/ko.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
||||||
|
statusTitle: 상태
|
||||||
|
startButton: 시작
|
||||||
|
statusReady: 준비됨
|
||||||
|
abortAllButton: 모두 중단
|
||||||
|
updateBinButton: yt-dlp 바이너리 업데이트
|
||||||
|
darkThemeButton: 다크 모드
|
||||||
|
lightThemeButton: 라이트 모드
|
||||||
|
settingsAnchor: 설정
|
||||||
|
serverAddressTitle: 서버 주소
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: 오디오 추출
|
||||||
|
noMTimeCheckbox: 파일 수정 시간을 설정하지 않음
|
||||||
|
bgReminder: 이 페이지를 닫아도 백그라운드에서 다운로드가 계속됩니다
|
||||||
|
toastConnected: '다음으로 연결됨 '
|
||||||
|
toastUpdated: yt-dlp 바이너리를 업데이트 했습니다
|
||||||
|
formatSelectionEnabler: 비디오/오디오 포멧 옵션 표시
|
||||||
|
themeSelect: 'Theme'
|
||||||
|
languageSelect: 'Language'
|
||||||
|
overridesAnchor: Overrides
|
||||||
|
pathOverrideOption: Enable output path overriding
|
||||||
|
filenameOverrideOption: Enable output file name overriding
|
||||||
|
autoFileExtensionOption: 자동으로 파일 확장자 추가
|
||||||
|
customFilename: Custom filename (leave blank to use default)
|
||||||
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
|
rpcConnErr: Error while conencting to RPC server
|
||||||
|
splashText: No active downloads
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: Copied URL to clipboard
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
80
frontend/src/assets/i18n/pl.yaml
Normal file
80
frontend/src/assets/i18n/pl.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
||||||
|
statusTitle: Status
|
||||||
|
startButton: Początek
|
||||||
|
statusReady: Gotowy
|
||||||
|
abortAllButton: Anuluj wszystko
|
||||||
|
updateBinButton: Zaktualizuj plik binarny yt-dlp
|
||||||
|
darkThemeButton: Ciemny motyw
|
||||||
|
lightThemeButton: Światło motyw
|
||||||
|
settingsAnchor: Ustawienia
|
||||||
|
serverAddressTitle: Adres serwera
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Wyodrębnij dźwięk
|
||||||
|
noMTimeCheckbox: Nie ustawiaj czasu modyfikacji pliku
|
||||||
|
bgReminder: Po zamknięciu tej strony pobieranie będzie kontynuowane w tle.
|
||||||
|
toastConnected: 'Połączony z '
|
||||||
|
toastUpdated: Zaktualizowano plik binarny yt-dlp!
|
||||||
|
formatSelectionEnabler: Aktywuj wybór formatów wideo/audio
|
||||||
|
themeSelect: 'Motyw'
|
||||||
|
languageSelect: 'Język'
|
||||||
|
overridesAnchor: Przedefiniuj
|
||||||
|
pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej
|
||||||
|
filenameOverrideOption: Aktywuj zastępowanie nazwy pliku źródłowego
|
||||||
|
autoFileExtensionOption: Automatyczne rozszerzenie pliku
|
||||||
|
customFilename: Wprowadź nazwę pliku (pozostaw puste, aby użyć nazwy domyślnej)
|
||||||
|
customPath: Ustaw ścieżkę
|
||||||
|
customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność)
|
||||||
|
customArgsInput: Niestandardowe argumenty yt-dlp
|
||||||
|
rpcConnErr: Wystąpił błąd podczas łączenia z serwerem RPC
|
||||||
|
splashText: Brak aktywnych pobrań
|
||||||
|
archiveTitle: Archiwum
|
||||||
|
clipboardAction: Adres URL zostanie skopiowany do schowka
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/pt_BR.yaml
Normal file
82
frontend/src/assets/i18n/pt_BR.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL do vídeo (uma por linha)
|
||||||
|
statusTitle: Status
|
||||||
|
statusReady: Pronto
|
||||||
|
selectFormatButton: Selecionar formato
|
||||||
|
startButton: Iniciar
|
||||||
|
abortAllButton: Cancelar tudo
|
||||||
|
updateBinButton: Atualizar binário yt-dlp
|
||||||
|
darkThemeButton: Tema escuro
|
||||||
|
lightThemeButton: Tema claro
|
||||||
|
settingsAnchor: Configurações
|
||||||
|
serverAddressTitle: Endereço do servidor
|
||||||
|
serverPortTitle: Porta
|
||||||
|
extractAudioCheckbox: Extrair áudio
|
||||||
|
noMTimeCheckbox: Não definir hora de modificação do arquivo
|
||||||
|
bgReminder: Uma vez que você feche esta página, o download continuará em segundo plano.
|
||||||
|
toastConnected: 'Conectado a '
|
||||||
|
toastUpdated: Binário yt-dlp atualizado!
|
||||||
|
formatSelectionEnabler: Habilitar seleção de formatos de vídeo/aúdio
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Idioma'
|
||||||
|
overridesAnchor: Substituições
|
||||||
|
pathOverrideOption: Habilitar substituição do caminho de saída
|
||||||
|
filenameOverrideOption: Habilitar substituição do nome do arquivo de saída
|
||||||
|
autoFileExtensionOption: Adicionar extensão de arquivo automaticamente
|
||||||
|
customFilename: Nome de arquivo personalizado (deixe em branco para usar o padrão)
|
||||||
|
customPath: Caminho personalizado
|
||||||
|
customArgs: Habilitar argumentos personalizados do yt-dlp (grandes poderes = grandes responsabilidades)
|
||||||
|
customArgsInput: Argumentos personalizados do yt-dlp
|
||||||
|
rpcConnErr: Erro ao conectar ao servidor RPC
|
||||||
|
splashText: Nenhum download ativo
|
||||||
|
archiveTitle: Arquivo
|
||||||
|
clipboardAction: URL copiada para a área de transferência
|
||||||
|
playlistCheckbox: Baixar playlist (isso pode levar algum tempo, depois de enviar você pode fechar esta janela)
|
||||||
|
restartAppMessage: Necessário recarregar a página para que a mudança tenha efeito
|
||||||
|
servedFromReverseProxyCheckbox: Está atrás de um proxy reverso
|
||||||
|
urlBase: Base da URL, para suporte de proxy reverso (subdiretório), padrão vazio
|
||||||
|
newDownloadButton: Novo download
|
||||||
|
homeButtonLabel: Início
|
||||||
|
archiveButtonLabel: Arquivo
|
||||||
|
settingsButtonLabel: Configurações
|
||||||
|
rpcAuthenticationLabel: Autenticação RPC
|
||||||
|
themeTogglerLabel: Alternador de tema
|
||||||
|
loadingLabel: Carregando...
|
||||||
|
appTitle: Título do aplicativo
|
||||||
|
savedTemplates: Modelos salvos
|
||||||
|
templatesEditor: Editor de modelos
|
||||||
|
templatesEditorNameLabel: Nome do modelo
|
||||||
|
templatesEditorContentLabel: Conteúdo do modelo
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Aguardando logs...'
|
||||||
|
bulkDownload: 'Baixar arquivos em um arquivo zip'
|
||||||
|
rpcPollingTimeTitle: Tempo de polling RPC
|
||||||
|
rpcPollingTimeDescription: Um intervalo menor resulta em maior uso de CPU (lado do servidor e do cliente)
|
||||||
|
templatesReloadInfo: Para registrar um novo modelo, pode ser necessário recarregar a página.
|
||||||
|
livestreamURLInput: URL da transmissão ao vivo
|
||||||
|
livestreamStatusWaiting: Aguardando/Aguarde o início
|
||||||
|
livestreamStatusDownloading: Baixando
|
||||||
|
livestreamStatusCompleted: Concluído
|
||||||
|
livestreamStatusErrored: Erro
|
||||||
|
livestreamStatusUnknown: Desconhecido
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
Isso monitorará uma transmissão ao vivo que ainda não começou. Cada processo será executado com --wait-for-video 10.
|
||||||
|
Se uma transmissão ao vivo já iniciada for fornecida, ela ainda será baixada, mas seu progresso não será rastreado.
|
||||||
|
Uma vez iniciada, a transmissão ao vivo será migrada para a página de downloads.
|
||||||
|
livestreamExperimentalWarning: Este recurso ainda é experimental. Algo pode quebrar!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
80
frontend/src/assets/i18n/ru.yaml
Normal file
80
frontend/src/assets/i18n/ru.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
||||||
|
statusTitle: Статус
|
||||||
|
startButton: Начать
|
||||||
|
statusReady: Готово
|
||||||
|
abortAllButton: Прервать все
|
||||||
|
updateBinButton: Обновить бинарный файл yt-dlp
|
||||||
|
darkThemeButton: Темная тема
|
||||||
|
lightThemeButton: Светлая тема
|
||||||
|
settingsAnchor: Настройки
|
||||||
|
serverAddressTitle: Адрес сервера
|
||||||
|
serverPortTitle: Порт
|
||||||
|
extractAudioCheckbox: Извлечь аудио
|
||||||
|
noMTimeCheckbox: Не устанавливать время модификации файла
|
||||||
|
bgReminder: Как только вы закроете эту страницу, загрузка продолжится в фоновом режиме.
|
||||||
|
toastConnected: 'Подключен к '
|
||||||
|
toastUpdated: Бинарный файл yt-dlp обновлен!
|
||||||
|
formatSelectionEnabler: Активировать выбор видео/аудио форматов
|
||||||
|
themeSelect: 'Тема'
|
||||||
|
languageSelect: 'Язык'
|
||||||
|
overridesAnchor: Переопределить
|
||||||
|
pathOverrideOption: Активировать переопределение выходного пути
|
||||||
|
filenameOverrideOption: Активировать переопределение имени выходного файла
|
||||||
|
autoFileExtensionOption: Автоматическое расширение файла
|
||||||
|
customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию)
|
||||||
|
customPath: Задать путь
|
||||||
|
customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность)
|
||||||
|
customArgsInput: Пользовательские аргументы yt-dlp
|
||||||
|
rpcConnErr: Ошибка при подключении к серверу RPC
|
||||||
|
splashText: Нет активных загрузок
|
||||||
|
archiveTitle: Архив
|
||||||
|
clipboardAction: URL скопирован в буфер обмена
|
||||||
|
playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно
|
||||||
|
servedFromReverseProxyCheckbox: Находится за обратным прокси
|
||||||
|
newDownloadButton: Новая загрузка
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Архив
|
||||||
|
settingsButtonLabel: Настройки
|
||||||
|
rpcAuthenticationLabel: RPC-аутентификация
|
||||||
|
themeTogglerLabel: Переключить тему
|
||||||
|
loadingLabel: Загрузка...
|
||||||
|
appTitle: Название приложения
|
||||||
|
savedTemplates: Сохраненные шаблоны
|
||||||
|
templatesEditor: Редактор шаблонов
|
||||||
|
templatesEditorNameLabel: Имя шаблона
|
||||||
|
templatesEditorContentLabel: Содержание шаблона
|
||||||
|
logsTitle: 'Логи'
|
||||||
|
awaitingLogs: 'Ожидание логов...'
|
||||||
|
bulkDownload: 'Скачать файлы в zip архиве'
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/sv.yaml
Normal file
82
frontend/src/assets/i18n/sv.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
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
|
||||||
|
autoFileExtensionOption: Lägg till filändelse automatiskt
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
80
frontend/src/assets/i18n/uk.yaml
Normal file
80
frontend/src/assets/i18n/uk.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
||||||
|
statusTitle: Статус
|
||||||
|
startButton: Почати
|
||||||
|
statusReady: Готово
|
||||||
|
abortAllButton: Перервати все
|
||||||
|
updateBinButton: Оновити бінарний файл yt-dlp
|
||||||
|
darkThemeButton: Темна тема
|
||||||
|
lightThemeButton: Світла тема
|
||||||
|
settingsAnchor: Налаштування
|
||||||
|
serverAddressTitle: Адреса сервера
|
||||||
|
serverPortTitle: Порт
|
||||||
|
extractAudioCheckbox: Витягти аудіо
|
||||||
|
noMTimeCheckbox: Не встановлювати час модифікації файлу
|
||||||
|
bgReminder: Як тільки ви закриєте цю сторінку, завантаження продовжиться у фоновому режимі.
|
||||||
|
toastConnected: 'Підключений до '
|
||||||
|
toastUpdated: Бінарний файл yt-dlp оновлено!
|
||||||
|
formatSelectionEnabler: Активувати вибір відео/аудіо форматів
|
||||||
|
themeSelect: 'Тема'
|
||||||
|
languageSelect: 'Мова'
|
||||||
|
overridesAnchor: Перевизначити
|
||||||
|
pathOverrideOption: Активувати перевизначення вихідного шляху
|
||||||
|
filenameOverrideOption: Активувати перевизначення імені вихідного файлу
|
||||||
|
autoFileExtensionOption: Автоматичне додавання розширення файлу
|
||||||
|
customFilename: Введіть ім'я файлу (залишіть порожнім, щоб використовувати значення за замовчуванням)
|
||||||
|
customPath: Задати шлях
|
||||||
|
customArgs: Включити аргументи, що настроюються yt-dlp (велика сила = велика відповідальність)
|
||||||
|
customArgsInput: Користувальницькі аргументи yt-dlp
|
||||||
|
rpcConnErr: Помилка при підключенні до сервера RPC
|
||||||
|
splashText: Немає активних завантажень
|
||||||
|
archiveTitle: Архів
|
||||||
|
clipboardAction: URL скопійовано в буфер обміну
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
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
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
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!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/zh_CN.yaml
Normal file
82
frontend/src/assets/i18n/zh_CN.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: 视频 URL
|
||||||
|
statusTitle: 状态
|
||||||
|
statusReady: 就绪
|
||||||
|
selectFormatButton: 选择格式
|
||||||
|
startButton: 开始
|
||||||
|
abortAllButton: 全部中止
|
||||||
|
updateBinButton: 更新 yt-dlp 可执行文件
|
||||||
|
darkThemeButton: 黑暗主题
|
||||||
|
lightThemeButton: 明亮主题
|
||||||
|
settingsAnchor: 设置
|
||||||
|
serverAddressTitle: 服务器地址
|
||||||
|
serverPortTitle: 端口
|
||||||
|
extractAudioCheckbox: 提取音频
|
||||||
|
noMTimeCheckbox: 不设置文件修改时间
|
||||||
|
bgReminder: 关闭页面后,下载会继续在后台运行。
|
||||||
|
toastConnected: '已连接到 '
|
||||||
|
toastUpdated: 已更新 yt-dlp 可执行文件!
|
||||||
|
formatSelectionEnabler: 启用视频/音频格式选择
|
||||||
|
themeSelect: '主题'
|
||||||
|
languageSelect: '语言'
|
||||||
|
overridesAnchor: 覆盖
|
||||||
|
pathOverrideOption: 启用输出路径覆盖
|
||||||
|
filenameOverrideOption: 启用输出文件名覆盖
|
||||||
|
autoFileExtensionOption: 自动文件扩展名
|
||||||
|
customFilename: 自定义文件名(留空使用默认值)
|
||||||
|
customPath: 自定义路径
|
||||||
|
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
|
||||||
|
customArgsInput: 自定义 yt-dlp 参数
|
||||||
|
rpcConnErr: 连接 RPC 服务器发生错误
|
||||||
|
splashText: 没有正在进行的下载
|
||||||
|
archiveTitle: 归档
|
||||||
|
clipboardAction: 复制 URL 到剪贴板
|
||||||
|
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
|
||||||
|
restartAppMessage: 需要刷新页面才能生效
|
||||||
|
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
|
||||||
|
newDownloadButton: 新下载
|
||||||
|
homeButtonLabel: 主页
|
||||||
|
archiveButtonLabel: 归档
|
||||||
|
settingsButtonLabel: 设置
|
||||||
|
rpcAuthenticationLabel: RPC 身份验证
|
||||||
|
themeTogglerLabel: 主题切换
|
||||||
|
loadingLabel: 正在加载…
|
||||||
|
appTitle: App 标题
|
||||||
|
savedTemplates: 保存模板
|
||||||
|
templatesEditor: 模板编辑器
|
||||||
|
templatesEditorNameLabel: 模板名称
|
||||||
|
templatesEditorContentLabel: 模板内容
|
||||||
|
logsTitle: '日志'
|
||||||
|
awaitingLogs: '正在等待日志…'
|
||||||
|
bulkDownload: '下载 zip 压缩包中的文件'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: 直播 URL
|
||||||
|
livestreamStatusWaiting: 等待直播开始
|
||||||
|
livestreamStatusDownloading: 下载中
|
||||||
|
livestreamStatusCompleted: 已完成
|
||||||
|
livestreamStatusErrored: 发生错误
|
||||||
|
livestreamStatusUnknown: 未知
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
本功能将会监控即将开始的直播流,每个进程都会传入参数:--wait-for-video 10 (重试间隔10秒)
|
||||||
|
如果直播已经开始,那么依然可以下载,但是不会记录下载进度。
|
||||||
|
直播开始后,将会转移到下载页面
|
||||||
|
livestreamExperimentalWarning: 实验性功能,可能存在未知Bug,请谨慎使用
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { getOrElse } from 'fp-ts/lib/Either'
|
import { getOrElse } from 'fp-ts/lib/Either'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { atom } from 'jotai'
|
||||||
|
import { atomWithStorage } from 'jotai/utils'
|
||||||
import { ffetch } from '../lib/httpClient'
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { CustomTemplate } from '../types'
|
import { CustomTemplate } from '../types'
|
||||||
import { serverSideCookiesState, serverURL } from './settings'
|
import { serverSideCookiesState, serverURL } from './settings'
|
||||||
import { atom } from 'jotai'
|
|
||||||
import { atomWithStorage } from 'jotai/utils'
|
|
||||||
|
|
||||||
export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
|
export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
|
||||||
await get(serverSideCookiesState)
|
await get(serverSideCookiesState)
|
||||||
@@ -22,12 +22,6 @@ export const filenameTemplateState = atomWithStorage(
|
|||||||
localStorage.getItem('lastFilenameTemplate') ?? ''
|
localStorage.getItem('lastFilenameTemplate') ?? ''
|
||||||
)
|
)
|
||||||
|
|
||||||
export const downloadTemplateState = atom<string>((get) =>
|
|
||||||
`${get(customArgsState)} ${get(cookiesTemplateState)}`
|
|
||||||
.replace(/ +/g, ' ')
|
|
||||||
.trim()
|
|
||||||
)
|
|
||||||
|
|
||||||
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
|
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
|
||||||
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
||||||
const either = await task()
|
const either = await task()
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { atom } from 'jotai'
|
|
||||||
import I18nBuilder from '../lib/intl'
|
|
||||||
import { languageState } from './settings'
|
|
||||||
|
|
||||||
export const i18nBuilderState = atom((get) => new I18nBuilder(get(languageState)))
|
|
||||||
@@ -6,19 +6,21 @@ import { atomWithStorage } from 'jotai/utils'
|
|||||||
import { atom } from 'jotai'
|
import { atom } from 'jotai'
|
||||||
|
|
||||||
export const languages = [
|
export const languages = [
|
||||||
'english',
|
|
||||||
'chinese',
|
|
||||||
'russian',
|
|
||||||
'french',
|
|
||||||
'italian',
|
|
||||||
'spanish',
|
|
||||||
'korean',
|
|
||||||
'japanese',
|
|
||||||
'catalan',
|
'catalan',
|
||||||
'ukrainian',
|
'chinese',
|
||||||
'swedish',
|
'english',
|
||||||
|
'french',
|
||||||
|
'german',
|
||||||
|
'italian',
|
||||||
|
'japanese',
|
||||||
|
'korean',
|
||||||
'polish',
|
'polish',
|
||||||
'german'
|
'portuguese-br',
|
||||||
|
'russian',
|
||||||
|
'spanish',
|
||||||
|
'swedish',
|
||||||
|
'ukrainian',
|
||||||
|
'hungarian'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type Language = (typeof languages)[number]
|
export type Language = (typeof languages)[number]
|
||||||
@@ -26,14 +28,19 @@ export type Language = (typeof languages)[number]
|
|||||||
export type Theme = 'light' | 'dark' | 'system'
|
export type Theme = 'light' | 'dark' | 'system'
|
||||||
export type ThemeNarrowed = 'light' | 'dark'
|
export type ThemeNarrowed = 'light' | 'dark'
|
||||||
|
|
||||||
|
export const accents = ['default', 'red'] as const
|
||||||
|
export type Accent = (typeof accents)[number]
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
serverAddr: string
|
serverAddr: string
|
||||||
serverPort: number
|
serverPort: number
|
||||||
language: Language
|
language: Language
|
||||||
theme: ThemeNarrowed
|
theme: ThemeNarrowed
|
||||||
|
accent: Accent
|
||||||
cliArgs: string
|
cliArgs: string
|
||||||
formatSelection: boolean
|
formatSelection: boolean
|
||||||
fileRenaming: boolean
|
fileRenaming: boolean
|
||||||
|
autoFileExtension: boolean
|
||||||
pathOverriding: boolean
|
pathOverriding: boolean
|
||||||
enableCustomArgs: boolean
|
enableCustomArgs: boolean
|
||||||
listView: boolean
|
listView: boolean
|
||||||
@@ -76,6 +83,11 @@ export const fileRenamingState = atomWithStorage(
|
|||||||
localStorage.getItem('file-renaming') === 'true'
|
localStorage.getItem('file-renaming') === 'true'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const autoFileExtensionState = atomWithStorage(
|
||||||
|
'auto-file-extension',
|
||||||
|
localStorage.getItem('auto-file-extension') === 'true'
|
||||||
|
)
|
||||||
|
|
||||||
export const pathOverridingState = atomWithStorage(
|
export const pathOverridingState = atomWithStorage(
|
||||||
'path-overriding',
|
'path-overriding',
|
||||||
localStorage.getItem('path-overriding') === 'true'
|
localStorage.getItem('path-overriding') === 'true'
|
||||||
@@ -109,11 +121,18 @@ export const appTitleState = atomWithStorage(
|
|||||||
export const serverAddressAndPortState = atom((get) => {
|
export const serverAddressAndPortState = atom((get) => {
|
||||||
if (get(servedFromReverseProxySubDirState)) {
|
if (get(servedFromReverseProxySubDirState)) {
|
||||||
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
|
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
|
||||||
|
.replaceAll('"', '') // XXX: atomWithStorage uses JSON.stringify to serialize
|
||||||
|
.replaceAll('//', '/') // which puts extra double quotes.
|
||||||
}
|
}
|
||||||
if (get(servedFromReverseProxyState)) {
|
if (get(servedFromReverseProxyState)) {
|
||||||
return `${get(serverAddressState)}`
|
return `${get(serverAddressState)}`
|
||||||
|
.replaceAll('"', '')
|
||||||
}
|
}
|
||||||
return `${get(serverAddressState)}:${get(serverPortState)}`
|
|
||||||
|
const sap = `${get(serverAddressState)}:${get(serverPortState)}`
|
||||||
|
.replaceAll('"', '')
|
||||||
|
|
||||||
|
return sap.endsWith('/') ? sap.slice(0, -1) : sap
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serverURL = atom((get) =>
|
export const serverURL = atom((get) =>
|
||||||
@@ -122,15 +141,17 @@ export const serverURL = atom((get) =>
|
|||||||
|
|
||||||
export const rpcWebSocketEndpoint = atom((get) => {
|
export const rpcWebSocketEndpoint = atom((get) => {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
|
const sap = get(serverAddressAndPortState)
|
||||||
}
|
|
||||||
)
|
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/ws`
|
||||||
|
})
|
||||||
|
|
||||||
export const rpcHTTPEndpoint = atom((get) => {
|
export const rpcHTTPEndpoint = atom((get) => {
|
||||||
const proto = window.location.protocol
|
const proto = window.location.protocol
|
||||||
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
|
const sap = get(serverAddressAndPortState)
|
||||||
}
|
|
||||||
)
|
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/http`
|
||||||
|
})
|
||||||
|
|
||||||
export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe(
|
export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe(
|
||||||
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
|
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
|
||||||
@@ -146,7 +167,11 @@ const themeSelector = atom<ThemeNarrowed>((get) => {
|
|||||||
return 'dark'
|
return 'dark'
|
||||||
}
|
}
|
||||||
return 'light'
|
return 'light'
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export const accentState = atomWithStorage<Accent>(
|
||||||
|
'accent-color',
|
||||||
|
localStorage.getItem('accent-color') as Accent ?? 'default',
|
||||||
)
|
)
|
||||||
|
|
||||||
export const settingsState = atom<SettingsState>((get) => ({
|
export const settingsState = atom<SettingsState>((get) => ({
|
||||||
@@ -154,13 +179,14 @@ export const settingsState = atom<SettingsState>((get) => ({
|
|||||||
serverPort: get(serverPortState),
|
serverPort: get(serverPortState),
|
||||||
language: get(languageState),
|
language: get(languageState),
|
||||||
theme: get(themeSelector),
|
theme: get(themeSelector),
|
||||||
|
accent: get(accentState),
|
||||||
cliArgs: get(latestCliArgumentsState),
|
cliArgs: get(latestCliArgumentsState),
|
||||||
formatSelection: get(formatSelectionState),
|
formatSelection: get(formatSelectionState),
|
||||||
fileRenaming: get(fileRenamingState),
|
fileRenaming: get(fileRenamingState),
|
||||||
|
autoFileExtension: get(autoFileExtensionState),
|
||||||
pathOverriding: get(pathOverridingState),
|
pathOverriding: get(pathOverridingState),
|
||||||
enableCustomArgs: get(enableCustomArgsState),
|
enableCustomArgs: get(enableCustomArgsState),
|
||||||
listView: get(listViewState),
|
listView: get(listViewState),
|
||||||
servedFromReverseProxy: get(servedFromReverseProxyState),
|
servedFromReverseProxy: get(servedFromReverseProxyState),
|
||||||
appTitle: get(appTitleState)
|
appTitle: get(appTitleState)
|
||||||
})
|
}))
|
||||||
)
|
|
||||||
|
|||||||
98
frontend/src/components/ArchiveCard.tsx
Normal file
98
frontend/src/components/ArchiveCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
|
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'
|
||||||
|
import SaveAltIcon from '@mui/icons-material/SaveAlt'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
IconButton,
|
||||||
|
Skeleton,
|
||||||
|
Tooltip,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { ArchiveEntry } from '../types'
|
||||||
|
import { base64URLEncode, ellipsis } from '../utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entry: ArchiveEntry
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onHardDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArchiveCard: React.FC<Props> = ({ entry, onDelete, onHardDelete }) => {
|
||||||
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const viewFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardActionArea onClick={() => navigator.clipboard.writeText(entry.source)}>
|
||||||
|
{entry.thumbnail !== '' ?
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height={180}
|
||||||
|
image={entry.thumbnail}
|
||||||
|
/> :
|
||||||
|
<Skeleton variant="rectangular" height={180} />
|
||||||
|
}
|
||||||
|
<CardContent>
|
||||||
|
{entry.title !== '' ?
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{ellipsis(entry.title, 60)}
|
||||||
|
</Typography> :
|
||||||
|
<Skeleton />
|
||||||
|
}
|
||||||
|
{/* <code>
|
||||||
|
{JSON.stringify(JSON.parse(entry.metadata), null, 2)}
|
||||||
|
</code> */}
|
||||||
|
<p>{new Date(entry.created_at).toLocaleString()}</p>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
<CardActions>
|
||||||
|
<Tooltip title="Open in browser">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => viewFile(entry.path)}
|
||||||
|
>
|
||||||
|
<OpenInBrowserIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Download this file">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => downloadFile(entry.path)}
|
||||||
|
>
|
||||||
|
<SaveAltIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete from archive">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onDelete(entry.id)}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete from disk">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onHardDelete(entry.id)}
|
||||||
|
>
|
||||||
|
<DeleteForeverIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArchiveCard
|
||||||
@@ -11,6 +11,9 @@ 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'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
const validateCookie = (cookie: string) => pipe(
|
const validateCookie = (cookie: string) => pipe(
|
||||||
cookie,
|
cookie,
|
||||||
@@ -164,7 +167,7 @@ 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>
|
<Button onClick={deleteCookies}>{i18n.t('deleteCookies')}</Button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
35
frontend/src/components/CustomArgsTextField.tsx
Normal file
35
frontend/src/components/CustomArgsTextField.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { TextField } from '@mui/material'
|
||||||
|
import { useAtom, useAtomValue } from 'jotai'
|
||||||
|
import { customArgsState } from '../atoms/downloadTemplate'
|
||||||
|
import { settingsState } from '../atoms/settings'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
const CustomArgsTextField: React.FC = () => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
const settings = useAtomValue(settingsState)
|
||||||
|
|
||||||
|
const [customArgs, setCustomArgs] = useAtom(customArgsState)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomArgs('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setCustomArgs(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('customArgsInput')}
|
||||||
|
variant="outlined"
|
||||||
|
onChange={handleCustomArgsChange}
|
||||||
|
value={customArgs}
|
||||||
|
disabled={settings.formatSelection}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomArgsTextField
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import EightK from '@mui/icons-material/EightK'
|
|
||||||
import FourK from '@mui/icons-material/FourK'
|
|
||||||
import Hd from '@mui/icons-material/Hd'
|
|
||||||
import Sd from '@mui/icons-material/Sd'
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -10,16 +6,23 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardMedia,
|
CardMedia,
|
||||||
Chip,
|
Chip,
|
||||||
|
IconButton,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tooltip,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { serverURL } from '../atoms/settings'
|
import { serverURL } from '../atoms/settings'
|
||||||
import { RPCResult } from '../types'
|
import { RPCResult } from '../types'
|
||||||
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
|
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
|
||||||
import { useAtomValue } from 'jotai'
|
import ResolutionBadge from './ResolutionBadge'
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear'
|
||||||
|
import StopCircleIcon from '@mui/icons-material/StopCircle'
|
||||||
|
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'
|
||||||
|
import SaveAltIcon from '@mui/icons-material/SaveAlt'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
download: RPCResult
|
download: RPCResult
|
||||||
@@ -27,15 +30,6 @@ type Props = {
|
|||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
|
|
||||||
if (!resolution) return null
|
|
||||||
if (resolution.includes('4320')) return <EightK color="primary" />
|
|
||||||
if (resolution.includes('2160')) return <FourK color="primary" />
|
|
||||||
if (resolution.includes('1080')) return <Hd color="primary" />
|
|
||||||
if (resolution.includes('720')) return <Sd color="primary" />
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
||||||
const serverAddr = useAtomValue(serverURL)
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
|
||||||
@@ -53,12 +47,12 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
|
|
||||||
const viewFile = (path: string) => {
|
const viewFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadFile = (path: string) => {
|
const downloadFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,37 +104,44 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
<Typography>
|
<Typography>
|
||||||
{formatSize(download.info.filesize_approx ?? 0)}
|
{formatSize(download.info.filesize_approx ?? 0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Resolution resolution={download.info.resolution} />
|
<ResolutionBadge resolution={download.info.resolution} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
<CardActions>
|
<CardActions>
|
||||||
<Button
|
{isCompleted() ?
|
||||||
variant="contained"
|
<Tooltip title="Clear from the view">
|
||||||
size="small"
|
<IconButton
|
||||||
color="primary"
|
onClick={onStop}
|
||||||
onClick={onStop}
|
>
|
||||||
>
|
<ClearIcon />
|
||||||
{isCompleted() ? "Clear" : "Stop"}
|
</IconButton>
|
||||||
</Button>
|
</Tooltip>
|
||||||
|
:
|
||||||
|
<Tooltip title="Stop this download">
|
||||||
|
<IconButton
|
||||||
|
onClick={onStop}
|
||||||
|
>
|
||||||
|
<StopCircleIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
{isCompleted() &&
|
{isCompleted() &&
|
||||||
<>
|
<>
|
||||||
<Button
|
<Tooltip title="Download this file">
|
||||||
variant="contained"
|
<IconButton
|
||||||
size="small"
|
onClick={() => downloadFile(download.output.savedFilePath)}
|
||||||
color="primary"
|
>
|
||||||
onClick={() => downloadFile(download.output.savedFilePath)}
|
<SaveAltIcon />
|
||||||
>
|
</IconButton>
|
||||||
Download
|
</Tooltip>
|
||||||
</Button>
|
<Tooltip title="Open in a new tab">
|
||||||
<Button
|
<IconButton
|
||||||
variant="contained"
|
onClick={() => viewFile(download.output.savedFilePath)}
|
||||||
size="small"
|
>
|
||||||
color="primary"
|
<OpenInBrowserIcon />
|
||||||
onClick={() => viewFile(download.output.savedFilePath)}
|
</IconButton>
|
||||||
>
|
</Tooltip>
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { FileUpload } from '@mui/icons-material'
|
|||||||
import CloseIcon from '@mui/icons-material/Close'
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Backdrop,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -12,7 +11,10 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
|
Select,
|
||||||
|
SelectChangeEvent,
|
||||||
TextField
|
TextField
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import AppBar from '@mui/material/AppBar'
|
import AppBar from '@mui/material/AppBar'
|
||||||
@@ -21,27 +23,32 @@ import Slide from '@mui/material/Slide'
|
|||||||
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 { TransitionProps } from '@mui/material/transitions'
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
|
import { useAtom, useAtomValue } from 'jotai'
|
||||||
import {
|
import {
|
||||||
FC,
|
FC,
|
||||||
Suspense,
|
Suspense,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate'
|
import {
|
||||||
|
cookiesTemplateState,
|
||||||
|
customArgsState,
|
||||||
|
filenameTemplateState,
|
||||||
|
savedTemplatesState
|
||||||
|
} from '../atoms/downloadTemplate'
|
||||||
import { settingsState } from '../atoms/settings'
|
import { settingsState } from '../atoms/settings'
|
||||||
import { availableDownloadPathsState, connectedState } from '../atoms/status'
|
import { availableDownloadPathsState, connectedState } from '../atoms/status'
|
||||||
import FormatsGrid from '../components/FormatsGrid'
|
import FormatsGrid from '../components/FormatsGrid'
|
||||||
|
import { 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 type { DLMetadata } from '../types'
|
import type { DLMetadata } from '../types'
|
||||||
import { toFormatArgs } from '../utils'
|
import { toFormatArgs } from '../utils'
|
||||||
|
import CustomArgsTextField from './CustomArgsTextField'
|
||||||
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
||||||
import { useToast } from '../hooks/toast'
|
|
||||||
import LoadingBackdrop from './LoadingBackdrop'
|
import LoadingBackdrop from './LoadingBackdrop'
|
||||||
import { useAtom, useAtomValue } from 'jotai'
|
|
||||||
|
|
||||||
const Transition = forwardRef(function Transition(
|
const Transition = forwardRef(function Transition(
|
||||||
props: TransitionProps & {
|
props: TransitionProps & {
|
||||||
@@ -62,8 +69,9 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
const settings = useAtomValue(settingsState)
|
const settings = useAtomValue(settingsState)
|
||||||
const isConnected = useAtomValue(connectedState)
|
const isConnected = useAtomValue(connectedState)
|
||||||
const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
|
const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
|
||||||
const downloadTemplate = useAtomValue(downloadTemplateState)
|
|
||||||
const savedTemplates = useAtomValue(savedTemplatesState)
|
const savedTemplates = useAtomValue(savedTemplatesState)
|
||||||
|
const customArgs = useAtomValue(customArgsState)
|
||||||
|
const cookies = useAtomValue(cookiesTemplateState)
|
||||||
|
|
||||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||||
@@ -71,14 +79,14 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||||
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
|
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
|
||||||
|
|
||||||
const [customArgs, setCustomArgs] = useAtom(customArgsState)
|
|
||||||
|
|
||||||
const [downloadPath, setDownloadPath] = useState('')
|
const [downloadPath, setDownloadPath] = useState('')
|
||||||
|
|
||||||
const [filenameTemplate, setFilenameTemplate] = useAtom(
|
const [filenameTemplate, setFilenameTemplate] = useAtom(
|
||||||
filenameTemplateState
|
filenameTemplateState
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [fileExtension, setFileExtension] = useState('.%(ext)s')
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
|
|
||||||
const [isPlaylist, setIsPlaylist] = useState(false)
|
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||||
@@ -92,10 +100,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCustomArgs('')
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||||
*/
|
*/
|
||||||
@@ -106,12 +110,16 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
||||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
||||||
|
|
||||||
|
const downloadTemplate = `${customArgs} ${cookies}`
|
||||||
|
.replace(/ +/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 10))
|
await new Promise(r => setTimeout(r, 10))
|
||||||
client.download({
|
client.download({
|
||||||
url: immediate || line,
|
url: immediate || line,
|
||||||
args: `${toFormatArgs(codes)} ${downloadTemplate}`,
|
args: `${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||||
pathOverride: downloadPath ?? '',
|
pathOverride: downloadPath ?? '',
|
||||||
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
renameTo: settings.fileRenaming ? filenameTemplate + (settings.autoFileExtension ? fileExtension : '') : '',
|
||||||
playlist: isPlaylist,
|
playlist: isPlaylist,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -165,8 +173,8 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
setFilenameTemplate(e.target.value)
|
setFilenameTemplate(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileExtensionChange = (e: SelectChangeEvent<string>) => {
|
||||||
setCustomArgs(e.target.value)
|
setFileExtension(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -264,25 +272,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||||
{
|
{settings.enableCustomArgs &&
|
||||||
settings.enableCustomArgs &&
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<CustomArgsTextField />
|
||||||
fullWidth
|
|
||||||
label={i18n.t('customArgsInput')}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={handleCustomArgsChange}
|
|
||||||
value={customArgs}
|
|
||||||
disabled={
|
|
||||||
!isConnected ||
|
|
||||||
(settings.formatSelection && downloadFormats != null)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
settings.fileRenaming &&
|
settings.fileRenaming &&
|
||||||
<Grid item xs={settings.pathOverriding ? 8 : 12}>
|
<Grid item xs={
|
||||||
|
!settings.autoFileExtension && !settings.pathOverriding
|
||||||
|
? 12
|
||||||
|
: !settings.autoFileExtension && settings.pathOverriding
|
||||||
|
? 8
|
||||||
|
: settings.autoFileExtension && !settings.pathOverriding
|
||||||
|
? 10
|
||||||
|
: 6
|
||||||
|
}>
|
||||||
<TextField
|
<TextField
|
||||||
sx={{ mt: 1 }}
|
sx={{ mt: 1 }}
|
||||||
ref={customFilenameInputRef}
|
ref={customFilenameInputRef}
|
||||||
@@ -298,6 +303,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
settings.autoFileExtension &&
|
||||||
|
<Grid item xs={2}>
|
||||||
|
<Select
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('autoFileExtension')}
|
||||||
|
value={fileExtension}
|
||||||
|
onChange={handleFileExtensionChange}
|
||||||
|
variant="outlined">
|
||||||
|
<MenuItem value=".%(ext)s">Auto</MenuItem>
|
||||||
|
<MenuItem value=".mp4">mp4</MenuItem>
|
||||||
|
<MenuItem value=".mkv">mkv</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
{
|
{
|
||||||
settings.pathOverriding &&
|
settings.pathOverriding &&
|
||||||
<Grid item xs={4}>
|
<Grid item xs={4}>
|
||||||
@@ -344,7 +365,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
disabled={url === ''}
|
disabled={url === ''}
|
||||||
onClick={() => settings.formatSelection
|
onClick={() => settings.formatSelection
|
||||||
? startTransition(() => sendUrlFormatSelection())
|
? startTransition(() => sendUrlFormatSelection())
|
||||||
: sendUrl()
|
: startTransition(async () => await sendUrl())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Grid } from '@mui/material'
|
import { Grid2 } from '@mui/material'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useTransition } from 'react'
|
||||||
import { activeDownloadsState } from '../atoms/downloads'
|
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 { ProcessStatus, RPCResult } from '../types'
|
||||||
import DownloadCard from './DownloadCard'
|
import DownloadCard from './DownloadCard'
|
||||||
|
import LoadingBackdrop from './LoadingBackdrop'
|
||||||
|
|
||||||
const DownloadsGridView: React.FC = () => {
|
const DownloadsGridView: React.FC = () => {
|
||||||
const downloads = useAtomValue(activeDownloadsState)
|
const downloads = useAtomValue(activeDownloadsState)
|
||||||
@@ -14,24 +16,31 @@ const DownloadsGridView: React.FC = () => {
|
|||||||
const { client } = useRPC()
|
const { client } = useRPC()
|
||||||
const { pushMessage } = useToast()
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
const [isPending, startTransition] = useTransition()
|
||||||
? client.clear(r.id)
|
|
||||||
: client.kill(r.id)
|
const stop = async (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
||||||
|
? await client.clear(r.id)
|
||||||
|
: await client.kill(r.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
|
<>
|
||||||
{
|
<LoadingBackdrop isLoading={isPending} />
|
||||||
downloads.map(download => (
|
<Grid2 container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
|
||||||
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
|
{
|
||||||
<DownloadCard
|
downloads.map(download => (
|
||||||
download={download}
|
<Grid2 size={{ xs: 4, sm: 8, md: 6, xl: 4 }} key={download.id}>
|
||||||
onStop={() => stop(download)}
|
<DownloadCard
|
||||||
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
download={download}
|
||||||
/>
|
onStop={() => startTransition(async () => {
|
||||||
</Grid>
|
await stop(download)
|
||||||
))
|
})}
|
||||||
}
|
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
||||||
</Grid>
|
/>
|
||||||
|
</Grid2>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Grid2>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,12 +125,12 @@ const DownloadsTableView: React.FC = () => {
|
|||||||
|
|
||||||
const viewFile = (path: string) => {
|
const viewFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadFile = (path: string) => {
|
const downloadFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
||||||
|
|||||||
45
frontend/src/components/EmptyArchive.tsx
Normal file
45
frontend/src/components/EmptyArchive.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import ArchiveIcon from '@mui/icons-material/Archive'
|
||||||
|
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||||
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
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 EmptyArchive() {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const activeDownloads = useAtomValue(activeDownloadsState)
|
||||||
|
|
||||||
|
if (activeDownloads.length !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlexContainer>
|
||||||
|
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||||
|
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||||
|
<ArchiveIcon />
|
||||||
|
</SvgIcon>
|
||||||
|
</Title>
|
||||||
|
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||||
|
{/* {i18n.t('splashText')} */}
|
||||||
|
Empty Archive
|
||||||
|
</Title>
|
||||||
|
</FlexContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
|
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
|
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { useAtom, useAtomValue } from 'jotai'
|
|
||||||
|
|
||||||
const ExtraDownloadOptions: React.FC = () => {
|
const ExtraDownloadOptions: React.FC = () => {
|
||||||
const { i18n } = useI18n()
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
const customTemplates = useAtomValue(savedTemplatesState)
|
const customTemplates = useAtomValue(savedTemplatesState)
|
||||||
const [, setCustomArgs] = useAtom(customArgsState)
|
const setCustomArgs = useSetAtom(customArgsState)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomArgs(
|
||||||
|
customTemplates
|
||||||
|
.find(f => f.name.toLocaleLowerCase() === 'default')
|
||||||
|
?.content ?? ''
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -17,7 +26,7 @@ const ExtraDownloadOptions: React.FC = () => {
|
|||||||
autoHighlight
|
autoHighlight
|
||||||
defaultValue={
|
defaultValue={
|
||||||
customTemplates
|
customTemplates
|
||||||
.filter(({ id, name }) => id === "0" || name === "default")
|
.filter(({ id, name }) => id === "0" || name.toLowerCase() === "default")
|
||||||
.map(({ name, content }) => ({ label: name, content }))
|
.map(({ name, content }) => ({ label: name, content }))
|
||||||
.at(0)
|
.at(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 ClearAllIcon from '@mui/icons-material/ClearAll'
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
import FolderZipIcon from '@mui/icons-material/FolderZip'
|
import FolderZipIcon from '@mui/icons-material/FolderZip'
|
||||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||||
@@ -34,7 +35,7 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
|||||||
>
|
>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
|
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
|
||||||
tooltipTitle={listView ? 'Card view' : 'Table view'}
|
tooltipTitle={listView ? 'Card view' : i18n.t('tableView')}
|
||||||
onClick={() => setListView(state => !state)}
|
onClick={() => setListView(state => !state)}
|
||||||
/>
|
/>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
@@ -42,6 +43,11 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
|||||||
tooltipTitle={i18n.t('bulkDownload')}
|
tooltipTitle={i18n.t('bulkDownload')}
|
||||||
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
|
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
|
||||||
/>
|
/>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<ClearAllIcon />}
|
||||||
|
tooltipTitle={i18n.t('clearCompletedButton')}
|
||||||
|
onClick={() => client.clearCompleted()}
|
||||||
|
/>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
icon={<DeleteForeverIcon />}
|
icon={<DeleteForeverIcon />}
|
||||||
tooltipTitle={i18n.t('abortAllButton')}
|
tooltipTitle={i18n.t('abortAllButton')}
|
||||||
|
|||||||
15
frontend/src/components/ResolutionBadge.tsx
Normal file
15
frontend/src/components/ResolutionBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import EightK from '@mui/icons-material/EightK'
|
||||||
|
import FourK from '@mui/icons-material/FourK'
|
||||||
|
import Hd from '@mui/icons-material/Hd'
|
||||||
|
import Sd from '@mui/icons-material/Sd'
|
||||||
|
|
||||||
|
const ResolutionBadge: React.FC<{ resolution?: string }> = ({ resolution }) => {
|
||||||
|
if (!resolution) return null
|
||||||
|
if (resolution.includes('4320')) return <EightK color="primary" />
|
||||||
|
if (resolution.includes('2160')) return <FourK color="primary" />
|
||||||
|
if (resolution.includes('1080')) return <Hd color="primary" />
|
||||||
|
if (resolution.includes('720')) return <Sd color="primary" />
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResolutionBadge
|
||||||
67
frontend/src/components/TemplateTextField.tsx
Normal file
67
frontend/src/components/TemplateTextField.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { FC, useState } from 'react'
|
||||||
|
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
TextField
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { CustomTemplate } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
template: CustomTemplate
|
||||||
|
onChange: (template: CustomTemplate) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TemplateTextField: FC<Props> = ({ template, onChange, onDelete }) => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
const [editedTemplate, setEditedTemplate] = useState(template)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
spacing={2}
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
key={template.id}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Grid item xs={3}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('templatesEditorNameLabel')}
|
||||||
|
defaultValue={template.name}
|
||||||
|
onChange={(e) => setEditedTemplate({ ...editedTemplate, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={9}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('templatesEditorContentLabel')}
|
||||||
|
defaultValue={template.content}
|
||||||
|
onChange={(e) => setEditedTemplate({ ...editedTemplate, content: e.target.value })}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <div style={{ display: 'flex', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
onClick={() => onChange(editedTemplate)}>
|
||||||
|
<EditIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
onClick={() => onDelete(editedTemplate.id)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemplateTextField
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import AddIcon from '@mui/icons-material/Add'
|
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 {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AppBar,
|
AppBar,
|
||||||
@@ -19,13 +18,14 @@ import {
|
|||||||
import { TransitionProps } from '@mui/material/transitions'
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
import { matchW } from 'fp-ts/lib/Either'
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
||||||
import { serverURL } from '../atoms/settings'
|
import { serverURL } from '../atoms/settings'
|
||||||
import { useToast } from '../hooks/toast'
|
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 { CustomTemplate } from '../types'
|
import { CustomTemplate } from '../types'
|
||||||
import { useAtomValue } from 'jotai'
|
import TemplateTextField from './TemplateTextField'
|
||||||
|
|
||||||
const Transition = forwardRef(function Transition(
|
const Transition = forwardRef(function Transition(
|
||||||
props: TransitionProps & {
|
props: TransitionProps & {
|
||||||
@@ -55,11 +55,11 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
getTemplates()
|
fetchTemplates()
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const getTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
const task = ffetch<CustomTemplate[]>(`${serverAddr}/api/v1/template/all`)
|
const task = ffetch<CustomTemplate[]>(`${serverAddr}/api/v1/template/all`)
|
||||||
const either = await task()
|
const either = await task()
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
(l) => pushMessage(l, 'warning'),
|
(l) => pushMessage(l, 'warning'),
|
||||||
() => {
|
() => {
|
||||||
pushMessage('Added template')
|
pushMessage('Added template')
|
||||||
getTemplates()
|
fetchTemplates()
|
||||||
setTemplateName('')
|
setTemplateName('')
|
||||||
setTemplateContent('')
|
setTemplateContent('')
|
||||||
}
|
}
|
||||||
@@ -97,6 +97,26 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateTemplate = async (template: CustomTemplate) => {
|
||||||
|
const task = ffetch<CustomTemplate>(`${serverAddr}/api/v1/template`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(template)
|
||||||
|
})
|
||||||
|
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'warning'),
|
||||||
|
(r) => {
|
||||||
|
pushMessage(`Updated template ${r.name}`)
|
||||||
|
fetchTemplates()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const deleteTemplate = async (id: string) => {
|
const deleteTemplate = async (id: string) => {
|
||||||
const task = ffetch<unknown>(`${serverAddr}/api/v1/template/${id}`, {
|
const task = ffetch<unknown>(`${serverAddr}/api/v1/template/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -110,7 +130,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
(l) => pushMessage(l, 'warning'),
|
(l) => pushMessage(l, 'warning'),
|
||||||
() => {
|
() => {
|
||||||
pushMessage('Deleted template')
|
pushMessage('Deleted template')
|
||||||
getTemplates()
|
fetchTemplates()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -179,7 +199,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: <Button
|
endAdornment: <Button
|
||||||
variant='contained'
|
variant='contained'
|
||||||
onClick={() => startTransition(() => { addTemplate() })}
|
onClick={() => startTransition(async () => await addTemplate())}
|
||||||
>
|
>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -188,38 +208,12 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
{templates.map(template => (
|
{templates.map(template => (
|
||||||
<Grid
|
<TemplateTextField
|
||||||
container
|
|
||||||
spacing={2}
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
key={template.id}
|
key={template.id}
|
||||||
sx={{ mt: 1 }}
|
template={template}
|
||||||
>
|
onChange={updateTemplate}
|
||||||
<Grid item xs={3}>
|
onDelete={deleteTemplate}
|
||||||
<TextField
|
/>
|
||||||
fullWidth
|
|
||||||
label={i18n.t('templatesEditorNameLabel')}
|
|
||||||
value={template.name}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={9}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={i18n.t('templatesEditorContentLabel')}
|
|
||||||
value={template.content}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: <Button
|
|
||||||
variant='contained'
|
|
||||||
onClick={() => {
|
|
||||||
startTransition(() => { deleteTemplate(template.id) })
|
|
||||||
}}>
|
|
||||||
<DeleteIcon />
|
|
||||||
</Button>
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
))}
|
))}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
22
frontend/src/components/TwitchIcon.tsx
Normal file
22
frontend/src/components/TwitchIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { settingsState } from '../atoms/settings'
|
||||||
|
|
||||||
|
const TwitchIcon: React.FC = () => {
|
||||||
|
const { theme } = useAtomValue(settingsState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ fill: theme === 'dark' ? '#fff' : '#757575' }}
|
||||||
|
>
|
||||||
|
<title>Twitch</title>
|
||||||
|
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchIcon
|
||||||
33
frontend/src/components/UpdateBinaryButton.tsx
Normal file
33
frontend/src/components/UpdateBinaryButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Button, CircularProgress } from '@mui/material'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useRPC } from '../hooks/useRPC'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
|
||||||
|
const UpdateBinaryButton: React.FC = () => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { client } = useRPC()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const updateBinary = () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
client
|
||||||
|
.updateExecutable()
|
||||||
|
.then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
|
||||||
|
.then(() => setIsLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
endIcon={isLoading ? <CircularProgress size={16} color='secondary' /> : <></>}
|
||||||
|
onClick={updateBinary}
|
||||||
|
>
|
||||||
|
{i18n.t('updateBinButton')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateBinaryButton
|
||||||
@@ -7,6 +7,7 @@ const VersionIndicator: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<Chip label={`UI v3.2.5`} variant="outlined" size="small" />
|
||||||
<Chip label={`RPC v${version.rpcVersion}`} variant="outlined" size="small" />
|
<Chip label={`RPC v${version.rpcVersion}`} variant="outlined" size="small" />
|
||||||
<Chip label={`yt-dlp v${version.ytdlpVersion}`} variant="outlined" size="small" />
|
<Chip label={`yt-dlp v${version.ytdlpVersion}`} variant="outlined" size="small" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function NoLivestreams() {
|
|||||||
</SvgIcon>
|
</SvgIcon>
|
||||||
</Title>
|
</Title>
|
||||||
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||||
No livestreams monitored
|
{i18n.t('livestreamNoMonitoring')}
|
||||||
</Title>
|
</Title>
|
||||||
</FlexContainer>
|
</FlexContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
38
frontend/src/components/subscriptions/NoSubscriptions.tsx
Normal file
38
frontend/src/components/subscriptions/NoSubscriptions.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import UpdateIcon from '@mui/icons-material/Update'
|
||||||
|
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 NoSubscriptions() {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlexContainer>
|
||||||
|
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||||
|
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||||
|
<UpdateIcon />
|
||||||
|
</SvgIcon>
|
||||||
|
</Title>
|
||||||
|
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||||
|
{i18n.t('subscriptionsEmptyLabel')}
|
||||||
|
</Title>
|
||||||
|
</FlexContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
frontend/src/components/subscriptions/SubscriptionsDialog.tsx
Normal file
164
frontend/src/components/subscriptions/SubscriptionsDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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 { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { forwardRef, startTransition, useState } from 'react'
|
||||||
|
import { customArgsState } from '../../atoms/downloadTemplate'
|
||||||
|
import { serverURL } from '../../atoms/settings'
|
||||||
|
import { useToast } from '../../hooks/toast'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
import { ffetch } from '../../lib/httpClient'
|
||||||
|
import { Subscription } from '../../services/subscriptions'
|
||||||
|
import ExtraDownloadOptions from '../ExtraDownloadOptions'
|
||||||
|
|
||||||
|
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 SubscriptionsDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [subscriptionURL, setSubscriptionURL] = useState('')
|
||||||
|
const [subscriptionCron, setSubscriptionCron] = useState('')
|
||||||
|
|
||||||
|
const customArgs = useAtomValue(customArgsState)
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const submit = async (sub: Omit<Subscription, 'id'>) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(sub)
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => onClose()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{i18n.t('subscriptionsButtonLabel')}
|
||||||
|
</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 gap={1.5}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity="info">
|
||||||
|
{i18n.t('subscriptionsInfo')}
|
||||||
|
</Alert>
|
||||||
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||||
|
{i18n.t('livestreamExperimentalWarning')}
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} mt={1}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('subscriptionsURLInput')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="https://www.youtube.com/@SomeChannelThatExists/videos"
|
||||||
|
onChange={(e) => setSubscriptionURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8} mt={-2}>
|
||||||
|
<ExtraDownloadOptions />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={3.871}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('cronExpressionLabel')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="*/5 * * * *"
|
||||||
|
onChange={(e) => setSubscriptionCron(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
variant="contained"
|
||||||
|
disabled={subscriptionURL === ''}
|
||||||
|
onClick={() => startTransition(() => submit({
|
||||||
|
url: subscriptionURL,
|
||||||
|
params: customArgs,
|
||||||
|
cron_expression: subscriptionCron
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{i18n.t('startButton')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsDialog
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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 { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { forwardRef, startTransition, useState } from 'react'
|
||||||
|
import { customArgsState } from '../../atoms/downloadTemplate'
|
||||||
|
import { serverURL } from '../../atoms/settings'
|
||||||
|
import { useToast } from '../../hooks/toast'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
import { ffetch } from '../../lib/httpClient'
|
||||||
|
import { Subscription } from '../../services/subscriptions'
|
||||||
|
import ExtraDownloadOptions from '../ExtraDownloadOptions'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
subscription: Subscription | undefined
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Transition = forwardRef(function Transition(
|
||||||
|
props: TransitionProps & {
|
||||||
|
children: React.ReactElement
|
||||||
|
},
|
||||||
|
ref: React.Ref<unknown>,
|
||||||
|
) {
|
||||||
|
return <Slide direction="up" ref={ref} {...props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
const SubscriptionsEditDialog: React.FC<Props> = ({ subscription, onClose }) => {
|
||||||
|
const [subscriptionURL, setSubscriptionURL] = useState('')
|
||||||
|
const [subscriptionCron, setSubscriptionCron] = useState('')
|
||||||
|
|
||||||
|
const customArgs = useAtomValue(customArgsState)
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const editSubscription = async (sub: Subscription) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(sub)
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => onClose()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullScreen
|
||||||
|
open={!!subscription}
|
||||||
|
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">
|
||||||
|
{i18n.t('subscriptionsButtonLabel')}
|
||||||
|
</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 gap={1.5}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity="info">
|
||||||
|
Editing {subscription?.url}
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} mt={1}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('subscriptionsURLInput')}
|
||||||
|
variant="outlined"
|
||||||
|
defaultValue={subscription?.url}
|
||||||
|
placeholder="https://www.youtube.com/@SomeChannelThatExists/videos"
|
||||||
|
onChange={(e) => setSubscriptionURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8} mt={-2}>
|
||||||
|
<ExtraDownloadOptions />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={3.871}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('cronExpressionLabel')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="*/5 * * * *"
|
||||||
|
defaultValue={subscription?.cron_expression}
|
||||||
|
onChange={(e) => setSubscriptionCron(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => startTransition(async () => await editSubscription({
|
||||||
|
id: subscription?.id ?? '',
|
||||||
|
url: subscriptionURL || subscription?.url!,
|
||||||
|
params: customArgs || subscription?.params!,
|
||||||
|
cron_expression: subscriptionCron || subscription?.cron_expression!
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{i18n.t('editButtonLabel')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsEditDialog
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||||
|
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubscriptionsSpeedDial: React.FC<Props> = ({ onOpen }) => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel="Subscriptions speed dial"
|
||||||
|
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||||
|
icon={<SpeedDialIcon />}
|
||||||
|
>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<AddCircleIcon />}
|
||||||
|
tooltipTitle={i18n.t('newSubscriptionButton')}
|
||||||
|
onClick={onOpen}
|
||||||
|
/>
|
||||||
|
</SpeedDial>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsSpeedDial
|
||||||
140
frontend/src/components/twitch/TwitchDialog.tsx
Normal file
140
frontend/src/components/twitch/TwitchDialog.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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 { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { forwardRef, startTransition, useState } from 'react'
|
||||||
|
import { serverURL } from '../../atoms/settings'
|
||||||
|
import { useToast } from '../../hooks/toast'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
import { ffetch } from '../../lib/httpClient'
|
||||||
|
|
||||||
|
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 TwitchDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [channelURL, setChannelURL] = useState('')
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const submit = async (channelURL: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/twitch/user`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: channelURL.split('/').at(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => onClose()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{i18n.t('subscriptionsButtonLabel')}
|
||||||
|
</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 gap={1.5}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity="info">
|
||||||
|
{i18n.t('twitchIntegrationInfo')}
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} mt={1}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('subscriptionsURLInput')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="https://www.twitch.tv/a_twitch_user_that_exists"
|
||||||
|
onChange={(e) => setChannelURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
variant="contained"
|
||||||
|
disabled={channelURL === ''}
|
||||||
|
onClick={() => startTransition(() => submit(channelURL))}
|
||||||
|
>
|
||||||
|
{i18n.t('startButton')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchDialog
|
||||||
35
frontend/src/hooks/useFetch.ts
Normal file
35
frontend/src/hooks/useFetch.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
|
||||||
|
const useFetch = <R>(resource: string) => {
|
||||||
|
const base = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const [data, setData] = useState<R>()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
|
const fetcher = () => pipe(
|
||||||
|
ffetch<R>(`${base}${resource}`),
|
||||||
|
matchW(
|
||||||
|
(l) => {
|
||||||
|
setError(l)
|
||||||
|
pushMessage(l, 'error')
|
||||||
|
},
|
||||||
|
(r) => setData(r)
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetcher()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { data, error, fetcher }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useFetch
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useAtomValue } from 'jotai'
|
import Translator from '../lib/i18n'
|
||||||
import { i18nBuilderState } from '../atoms/i18n'
|
|
||||||
|
|
||||||
export const useI18n = () => {
|
export const useI18n = () => {
|
||||||
const instance = useAtomValue(i18nBuilderState)
|
const instance = Translator.instance
|
||||||
|
|
||||||
return {
|
return {
|
||||||
i18n: instance
|
i18n: instance,
|
||||||
|
t: instance.t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import { tryCatch } from 'fp-ts/TaskEither'
|
import { tryCatch } from 'fp-ts/TaskEither'
|
||||||
|
import * as J from 'fp-ts/Json'
|
||||||
|
import * as E from 'fp-ts/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
|
||||||
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise<string> {
|
||||||
() => fetcher<T>(url, opt),
|
|
||||||
(e) => `error while fetching: ${e}`
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
|
||||||
const jwt = localStorage.getItem('token')
|
const jwt = localStorage.getItem('token')
|
||||||
|
|
||||||
if (opt && !opt.headers) {
|
if (opt && !opt.headers) {
|
||||||
@@ -20,11 +17,27 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
|||||||
headers: {
|
headers: {
|
||||||
...opt?.headers,
|
...opt?.headers,
|
||||||
'X-Authentication': jwt ?? ''
|
'X-Authentication': jwt ?? ''
|
||||||
}
|
},
|
||||||
|
signal: controller?.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw await res.text()
|
throw await res.text()
|
||||||
}
|
}
|
||||||
return res.json() as T
|
|
||||||
|
|
||||||
|
|
||||||
|
return res.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ffetch = <T>(url: string, opt?: RequestInit, controller?: AbortController) => tryCatch(
|
||||||
|
async () => pipe(
|
||||||
|
await fetcher(url, opt, controller),
|
||||||
|
J.parse,
|
||||||
|
E.match(
|
||||||
|
(l) => l as T,
|
||||||
|
(r) => r as T
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(e) => `error while fetching: ${e}`
|
||||||
|
)
|
||||||
|
|||||||
53
frontend/src/lib/i18n.ts
Normal file
53
frontend/src/lib/i18n.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//@ts-ignore
|
||||||
|
import i18n from '../assets/i18n.yaml'
|
||||||
|
//@ts-ignore
|
||||||
|
import fallback from '../assets/i18n/en_US.yaml'
|
||||||
|
|
||||||
|
export default class Translator {
|
||||||
|
static #instance: Translator
|
||||||
|
|
||||||
|
private language: string
|
||||||
|
private current: string[] = []
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.language = localStorage.getItem('language')?.replaceAll('"', '') ?? 'english'
|
||||||
|
this.setLanguage(this.language)
|
||||||
|
}
|
||||||
|
|
||||||
|
getLanguage(): string {
|
||||||
|
return this.language
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLanguage(language: string): Promise<void> {
|
||||||
|
this.language = language
|
||||||
|
|
||||||
|
let isoCodeFile: string = i18n.languages[language]
|
||||||
|
|
||||||
|
// extension needs to be in source code to help vite bundle all yaml files
|
||||||
|
if (isoCodeFile.endsWith('.yaml')) {
|
||||||
|
isoCodeFile = isoCodeFile.replaceAll('.yaml', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isoCodeFile) {
|
||||||
|
const { default: translations } = await import(`../assets/i18n/${isoCodeFile}.yaml`)
|
||||||
|
|
||||||
|
this.current = translations.keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t(key: string): string {
|
||||||
|
if (this.current) {
|
||||||
|
//@ts-ignore
|
||||||
|
return this.current[key] ?? fallback.keys[key] ?? 'caption not defined'
|
||||||
|
}
|
||||||
|
return 'caption not defined'
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get instance(): Translator {
|
||||||
|
if (!Translator.#instance) {
|
||||||
|
Translator.#instance = new Translator()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Translator.#instance
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import i18n from "../assets/i18n.yaml"
|
|
||||||
|
|
||||||
export default class I18nBuilder {
|
|
||||||
private language: string
|
|
||||||
private textMap = i18n.languages
|
|
||||||
private current: string[]
|
|
||||||
|
|
||||||
constructor(language: string) {
|
|
||||||
this.setLanguage(language)
|
|
||||||
}
|
|
||||||
|
|
||||||
getLanguage(): string {
|
|
||||||
return this.language
|
|
||||||
}
|
|
||||||
|
|
||||||
setLanguage(language: string): void {
|
|
||||||
this.language = language
|
|
||||||
this.current = this.textMap[this.language]
|
|
||||||
}
|
|
||||||
|
|
||||||
t(key: string): string {
|
|
||||||
if (this.current) {
|
|
||||||
return this.current[key] ?? 'caption not defined'
|
|
||||||
}
|
|
||||||
return 'caption not defined'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,11 +41,13 @@ export class RPCClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private argsSanitizer(args: string) {
|
private argsSanitizer(args: string): string[] {
|
||||||
|
const splitOnlyWhitespaces = /[^\s"']+|"([^"]*)"|'([^']*)'/gm
|
||||||
|
|
||||||
return args
|
return args
|
||||||
.split(' ')
|
.match(splitOnlyWhitespaces)
|
||||||
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
|
?.map(a => a.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendHTTP<T>(req: RPCRequest) {
|
private async sendHTTP<T>(req: RPCRequest) {
|
||||||
@@ -128,21 +130,21 @@ export class RPCClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public kill(id: string) {
|
public kill(id: string) {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.Kill',
|
method: 'Service.Kill',
|
||||||
params: [id],
|
params: [id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear(id: string) {
|
public clear(id: string) {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.Clear',
|
method: 'Service.Clear',
|
||||||
params: [id],
|
params: [id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public killAll() {
|
public killAll() {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.KillAll',
|
method: 'Service.KillAll',
|
||||||
params: [],
|
params: [],
|
||||||
})
|
})
|
||||||
@@ -191,4 +193,18 @@ export class RPCClient {
|
|||||||
params: []
|
params: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public updateExecutable() {
|
||||||
|
return this.sendHTTP({
|
||||||
|
method: 'Service.UpdateExecutable',
|
||||||
|
params: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCompleted() {
|
||||||
|
return this.sendHTTP({
|
||||||
|
method: 'Service.ClearCompleted',
|
||||||
|
params: []
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,12 @@ import Terminal from './views/Terminal'
|
|||||||
|
|
||||||
const Home = lazy(() => import('./views/Home'))
|
const Home = lazy(() => import('./views/Home'))
|
||||||
const Login = lazy(() => import('./views/Login'))
|
const Login = lazy(() => import('./views/Login'))
|
||||||
|
const Twitch = lazy(() => import('./views/Twitch'))
|
||||||
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 LiveStream = lazy(() => import('./views/Livestream'))
|
||||||
|
const Filebrowser = lazy(() => import('./views/Filebrowser'))
|
||||||
|
const Subscriptions = lazy(() => import('./views/Subscriptions'))
|
||||||
|
|
||||||
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
||||||
|
|
||||||
@@ -59,6 +62,32 @@ export const router = createHashRouter([
|
|||||||
</Suspense >
|
</Suspense >
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/filebrowser',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Filebrowser />
|
||||||
|
</Suspense >
|
||||||
|
),
|
||||||
|
errorElement: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<ErrorBoundary />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/subscriptions',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Subscriptions />
|
||||||
|
</Suspense >
|
||||||
|
),
|
||||||
|
errorElement: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<ErrorBoundary />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
element: (
|
element: (
|
||||||
@@ -83,6 +112,14 @@ export const router = createHashRouter([
|
|||||||
</Suspense >
|
</Suspense >
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/twitch',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Twitch />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
37
frontend/src/services/subscriptions.ts
Normal file
37
frontend/src/services/subscriptions.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// import { PaginatedResponse } from '../types'
|
||||||
|
|
||||||
|
export type Subscription = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
params: string
|
||||||
|
cron_expression: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// class SubscriptionService {
|
||||||
|
// private _baseURL: string = ''
|
||||||
|
|
||||||
|
// public set baseURL(v: string) {
|
||||||
|
// this._baseURL = v
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async delete(id: string): Promise<void> {
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async listPaginated(start: number, limit: number = 50): Promise<PaginatedResponse<Subscription[]>> {
|
||||||
|
// const res = await fetch(`${this._baseURL}/subscriptions?id=${start}&limit=${limit}`)
|
||||||
|
// const data: PaginatedResponse<Subscription[]> = await res.json()
|
||||||
|
|
||||||
|
// return data
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async submit(sub: Subscription): Promise<void> {
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async edit(sub: Subscription): Promise<void> {
|
||||||
|
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export default SubscriptionService
|
||||||
@@ -13,6 +13,7 @@ export type RPCMethods =
|
|||||||
| "Service.ProgressLivestream"
|
| "Service.ProgressLivestream"
|
||||||
| "Service.KillLivestream"
|
| "Service.KillLivestream"
|
||||||
| "Service.KillAllLivestream"
|
| "Service.KillAllLivestream"
|
||||||
|
| "Service.ClearCompleted"
|
||||||
|
|
||||||
export type RPCRequest = {
|
export type RPCRequest = {
|
||||||
method: RPCMethods
|
method: RPCMethods
|
||||||
@@ -123,3 +124,19 @@ export type RPCVersion = {
|
|||||||
rpcVersion: string
|
rpcVersion: string
|
||||||
ytdlpVersion: string
|
ytdlpVersion: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ArchiveEntry = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
thumbnail: string
|
||||||
|
source: string
|
||||||
|
metadata: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaginatedResponse<T> = {
|
||||||
|
first: number
|
||||||
|
next: number
|
||||||
|
data: T
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { blue, red } from '@mui/material/colors'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { Accent, ThemeNarrowed } from './atoms/settings'
|
||||||
import type { RPCResponse } from "./types"
|
import type { RPCResponse } from "./types"
|
||||||
import { ProcessStatus } from './types'
|
import { ProcessStatus } from './types'
|
||||||
|
|
||||||
@@ -80,3 +82,14 @@ export const base64URLEncode = (s: string) => pipe(
|
|||||||
btoa,
|
btoa,
|
||||||
encodeURIComponent
|
encodeURIComponent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const getAccentValue = (accent: Accent, mode: ThemeNarrowed) => {
|
||||||
|
switch (accent) {
|
||||||
|
case 'default':
|
||||||
|
return mode === 'light' ? blue[700] : blue[300]
|
||||||
|
case 'red':
|
||||||
|
return mode === 'light' ? red[600] : red[400]
|
||||||
|
default:
|
||||||
|
return mode === 'light' ? blue[700] : blue[300]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,363 +1,112 @@
|
|||||||
import {
|
import { Container, FormControl, Grid2, InputLabel, MenuItem, Pagination, Select } from '@mui/material'
|
||||||
Backdrop,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
CircularProgress,
|
|
||||||
Container,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
MenuItem,
|
|
||||||
MenuList,
|
|
||||||
Paper,
|
|
||||||
SpeedDial,
|
|
||||||
SpeedDialAction,
|
|
||||||
SpeedDialIcon,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
|
||||||
import FolderIcon from '@mui/icons-material/Folder'
|
|
||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
|
||||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
|
||||||
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download'
|
|
||||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
|
||||||
import { serverURL } from '../atoms/settings'
|
|
||||||
import { useObservable } from '../hooks/observable'
|
|
||||||
import { useToast } from '../hooks/toast'
|
|
||||||
import { useI18n } from '../hooks/useI18n'
|
|
||||||
import { ffetch } from '../lib/httpClient'
|
|
||||||
import { DirectoryEntry } from '../types'
|
|
||||||
import { base64URLEncode, formatSize } from '../utils'
|
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useEffect, useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import ArchiveCard from '../components/ArchiveCard'
|
||||||
|
import EmptyArchive from '../components/EmptyArchive'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { ArchiveEntry, PaginatedResponse } from '../types'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
|
||||||
export default function Downloaded() {
|
const Archive: React.FC = () => {
|
||||||
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
const [isLoading, setLoading] = useState(true)
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [archiveEntries, setArchiveEntries] = useState<ArchiveEntry[]>()
|
||||||
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
|
||||||
|
|
||||||
const serverAddr = useAtomValue(serverURL)
|
const [currentCursor, setCurrentCursor] = useState(0)
|
||||||
const navigate = useNavigate()
|
const [cursor, setCursor] = useState({ first: 0, next: 0 })
|
||||||
|
const [pageSize, setPageSize] = useState(25)
|
||||||
const { i18n } = useI18n()
|
|
||||||
const { pushMessage } = useToast()
|
|
||||||
|
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
|
||||||
|
|
||||||
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
|
||||||
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const fetcher = () => pipe(
|
const serverAddr = useAtomValue(serverURL)
|
||||||
ffetch<DirectoryEntry[]>(
|
const { pushMessage } = useToast()
|
||||||
`${serverAddr}/archive/downloaded`,
|
|
||||||
{
|
const fetchArchived = (startCursor = 0) => pipe(
|
||||||
method: 'POST',
|
ffetch<PaginatedResponse<ArchiveEntry[]>>(`${serverAddr}/archive?id=${startCursor}&limit=${pageSize}`),
|
||||||
body: JSON.stringify({
|
|
||||||
subdir: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
),
|
|
||||||
matchW(
|
matchW(
|
||||||
(e) => {
|
(l) => pushMessage(l, 'error'),
|
||||||
pushMessage(e, 'error')
|
(r) => {
|
||||||
navigate('/login')
|
setArchiveEntries(r.data)
|
||||||
},
|
setCursor({ ...cursor, first: r.first, next: r.next })
|
||||||
(d) => files$.next(d ?? []),
|
}
|
||||||
)
|
)
|
||||||
)()
|
)()
|
||||||
|
|
||||||
const fetcherSubfolder = (sub: string) => {
|
const softDelete = (id: string) => pipe(
|
||||||
const folders = sub.startsWith('/')
|
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/soft/${id}`, {
|
||||||
? sub.substring(1).split('/')
|
method: 'DELETE'
|
||||||
: sub.split('/')
|
|
||||||
|
|
||||||
const relpath = folders.length >= 2
|
|
||||||
? folders.slice(-(folders.length - 1)).join('/')
|
|
||||||
: folders.pop()
|
|
||||||
|
|
||||||
const _upperLevel = folders.slice(1, -1)
|
|
||||||
const upperLevel = _upperLevel.length === 2
|
|
||||||
? ['.', ..._upperLevel].join('/')
|
|
||||||
: _upperLevel.join('/')
|
|
||||||
|
|
||||||
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ subdir: relpath })
|
|
||||||
})
|
|
||||||
|
|
||||||
pipe(
|
|
||||||
task,
|
|
||||||
matchW(
|
|
||||||
(l) => pushMessage(l, 'error'),
|
|
||||||
(r) => files$.next(sub
|
|
||||||
? [{
|
|
||||||
isDirectory: true,
|
|
||||||
isVideo: false,
|
|
||||||
modTime: '',
|
|
||||||
name: '..',
|
|
||||||
path: upperLevel,
|
|
||||||
size: 0,
|
|
||||||
}, ...r.filter(f => f.name !== '')]
|
|
||||||
: r.filter(f => f.name !== '')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)()
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectable$ = useMemo(() => files$.pipe(
|
|
||||||
combineLatestWith(selected$),
|
|
||||||
map(([data, selected]) => data.map(x => ({
|
|
||||||
...x,
|
|
||||||
selected: selected.includes(x.name)
|
|
||||||
}))),
|
|
||||||
share()
|
|
||||||
), [])
|
|
||||||
|
|
||||||
const selectable = useObservable(selectable$, [])
|
|
||||||
|
|
||||||
const addSelected = (name: string) => {
|
|
||||||
selected$.value.includes(name)
|
|
||||||
? selected$.next(selected$.value.filter(val => val !== name))
|
|
||||||
: selected$.next([...selected$.value, name])
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteFile = (entry: DirectoryEntry) => pipe(
|
|
||||||
ffetch(`${serverAddr}/archive/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
path: entry.path,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
matchW(
|
matchW(
|
||||||
(l) => pushMessage(l, 'error'),
|
(l) => pushMessage(l, 'error'),
|
||||||
(_) => fetcher()
|
(_) => startTransition(async () => await fetchArchived())
|
||||||
)
|
)
|
||||||
)()
|
)()
|
||||||
|
|
||||||
const deleteSelected = () => {
|
const hardDelete = (id: string) => pipe(
|
||||||
Promise.all(selectable
|
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/hard/${id}`, {
|
||||||
.filter(entry => entry.selected)
|
method: 'DELETE'
|
||||||
.map(deleteFile)
|
}),
|
||||||
).then(fetcher)
|
matchW(
|
||||||
}
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => startTransition(async () => await fetchArchived())
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const setPage = (page: number) => setCurrentCursor(pageSize * (page - 1))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetcher()
|
fetchArchived(currentCursor).then(() => setLoading(false))
|
||||||
}, [serverAddr])
|
}, [currentCursor])
|
||||||
|
|
||||||
const onFileClick = (path: string) => startTransition(() => {
|
|
||||||
const encoded = base64URLEncode(path)
|
|
||||||
|
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const downloadFile = (path: string) => startTransition(() => {
|
|
||||||
const encoded = base64URLEncode(path)
|
|
||||||
|
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const onFolderClick = (path: string) => startTransition(() => {
|
|
||||||
fetcherSubfolder(path)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8, minHeight: '80vh' }}>
|
||||||
maxWidth="xl"
|
<LoadingBackdrop isLoading={isPending || isLoading} />
|
||||||
sx={{ mt: 4, mb: 4, height: '100%' }}
|
{
|
||||||
onClick={() => setShowMenu(false)}
|
archiveEntries && archiveEntries.length !== 0 ?
|
||||||
>
|
<Grid2
|
||||||
<IconMenu
|
container
|
||||||
posX={menuPos.x}
|
spacing={{ xs: 2, md: 2 }}
|
||||||
posY={menuPos.y}
|
columns={{ xs: 4, sm: 8, md: 12, xl: 12 }}
|
||||||
hide={!showMenu}
|
pt={2}
|
||||||
onDownload={() => {
|
sx={{ minHeight: '77.5vh' }}
|
||||||
if (currentFile) {
|
|
||||||
downloadFile(currentFile?.path)
|
|
||||||
setCurrentFile(undefined)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
if (currentFile) {
|
|
||||||
deleteFile(currentFile)
|
|
||||||
setCurrentFile(undefined)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Backdrop
|
|
||||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
|
||||||
open={!(files$.observed) || isPending}
|
|
||||||
>
|
|
||||||
<CircularProgress color="primary" />
|
|
||||||
</Backdrop>
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
onClick={() => setShowMenu(false)}
|
|
||||||
>
|
|
||||||
<Typography py={1} variant="h5" color="primary">
|
|
||||||
{i18n.t('archiveTitle')}
|
|
||||||
</Typography>
|
|
||||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
|
||||||
{selectable.length === 0 && 'No files found'}
|
|
||||||
{selectable.map((file, idx) => (
|
|
||||||
<ListItem
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setCurrentFile(file)
|
|
||||||
setMenuPos({ x: e.clientX, y: e.clientY })
|
|
||||||
setShowMenu(true)
|
|
||||||
}}
|
|
||||||
key={idx}
|
|
||||||
secondaryAction={
|
|
||||||
<div>
|
|
||||||
{!file.isDirectory && <Typography
|
|
||||||
variant="caption"
|
|
||||||
component="span"
|
|
||||||
>
|
|
||||||
{formatSize(file.size)}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
{!file.isDirectory && <>
|
|
||||||
<Checkbox
|
|
||||||
edge="end"
|
|
||||||
checked={file.selected}
|
|
||||||
onChange={() => addSelected(file.name)}
|
|
||||||
/>
|
|
||||||
</>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
disablePadding
|
|
||||||
>
|
|
||||||
<ListItemButton onClick={
|
|
||||||
() => file.isDirectory
|
|
||||||
? onFolderClick(file.path)
|
|
||||||
: onFileClick(file.path)
|
|
||||||
}>
|
|
||||||
<ListItemIcon>
|
|
||||||
{file.isDirectory
|
|
||||||
? <FolderIcon />
|
|
||||||
: file.isVideo
|
|
||||||
? <VideoFileIcon />
|
|
||||||
: <InsertDriveFileIcon />
|
|
||||||
}
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={file.name}
|
|
||||||
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
<SpeedDial
|
|
||||||
ariaLabel='archive actions'
|
|
||||||
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
|
||||||
icon={<SpeedDialIcon />}
|
|
||||||
>
|
|
||||||
<SpeedDialAction
|
|
||||||
icon={<DeleteForeverIcon />}
|
|
||||||
tooltipTitle={`Delete selected`}
|
|
||||||
tooltipOpen
|
|
||||||
onClick={() => {
|
|
||||||
if (selected$.value.length > 0) {
|
|
||||||
setOpenDialog(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SpeedDial>
|
|
||||||
<Dialog
|
|
||||||
open={openDialog}
|
|
||||||
onClose={() => setOpenDialog(false)}
|
|
||||||
>
|
|
||||||
<DialogTitle>
|
|
||||||
Are you sure?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText id="alert-dialog-description">
|
|
||||||
You're deleting:
|
|
||||||
</DialogContentText>
|
|
||||||
<ul>
|
|
||||||
{selected$.value.map((entry, idx) => (
|
|
||||||
<li key={idx}>{entry}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setOpenDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
deleteSelected()
|
|
||||||
setOpenDialog(false)
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
>
|
>
|
||||||
Ok
|
{
|
||||||
</Button>
|
archiveEntries.map((entry) => (
|
||||||
</DialogActions>
|
<Grid2 size={{ xs: 4, sm: 8, md: 4, xl: 3 }} key={entry.id}>
|
||||||
</Dialog>
|
<ArchiveCard
|
||||||
|
entry={entry}
|
||||||
|
onDelete={() => startTransition(async () => await softDelete(entry.id))}
|
||||||
|
onHardDelete={() => startTransition(async () => await hardDelete(entry.id))}
|
||||||
|
/>
|
||||||
|
</Grid2>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Grid2>
|
||||||
|
: <EmptyArchive />
|
||||||
|
}
|
||||||
|
<Pagination
|
||||||
|
sx={{ mx: 'auto', pt: 2 }}
|
||||||
|
count={Math.floor(cursor.next / pageSize) + 1}
|
||||||
|
onChange={(_, v) => setPage(v)}
|
||||||
|
/>
|
||||||
|
<FormControl variant="standard" sx={{ m: 1, minWidth: 120 }}>
|
||||||
|
<InputLabel id="page-size-select-label">Page size</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="page-size-select-label"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||||
|
label="Page size"
|
||||||
|
>
|
||||||
|
<MenuItem value={25}>25</MenuItem>
|
||||||
|
<MenuItem value={50}>50</MenuItem>
|
||||||
|
<MenuItem value={100}>100</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconMenu: React.FC<{
|
export default Archive
|
||||||
posX: number
|
|
||||||
posY: number
|
|
||||||
hide: boolean
|
|
||||||
onDownload: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
|
|
||||||
return (
|
|
||||||
<Paper sx={{
|
|
||||||
width: 320,
|
|
||||||
maxWidth: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
top: posY,
|
|
||||||
left: posX,
|
|
||||||
display: hide ? 'none' : 'block',
|
|
||||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
|
||||||
}}>
|
|
||||||
<MenuList>
|
|
||||||
<MenuItem onClick={onDownload}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<DownloadIcon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>
|
|
||||||
Download
|
|
||||||
</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={onDelete}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<DeleteForeverIcon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>
|
|
||||||
Delete
|
|
||||||
</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
360
frontend/src/views/Filebrowser.tsx
Normal file
360
frontend/src/views/Filebrowser.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import {
|
||||||
|
Backdrop,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Paper,
|
||||||
|
SpeedDial,
|
||||||
|
SpeedDialAction,
|
||||||
|
SpeedDialIcon,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder'
|
||||||
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||||
|
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||||
|
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { useObservable } from '../hooks/observable'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { DirectoryEntry } from '../types'
|
||||||
|
import { base64URLEncode, formatSize } from '../utils'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
export default function Downloaded() {
|
||||||
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
||||||
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
||||||
|
|
||||||
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
||||||
|
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const fetcher = () => pipe(
|
||||||
|
ffetch<DirectoryEntry[]>(
|
||||||
|
`${serverAddr}/filebrowser/downloaded`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
subdir: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
),
|
||||||
|
matchW(
|
||||||
|
(e) => {
|
||||||
|
pushMessage(e, 'error')
|
||||||
|
navigate('/login')
|
||||||
|
},
|
||||||
|
(d) => files$.next(d ?? []),
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const fetcherSubfolder = (sub: string) => {
|
||||||
|
const folders = sub.startsWith('/')
|
||||||
|
? sub.substring(1).split('/')
|
||||||
|
: sub.split('/')
|
||||||
|
|
||||||
|
const relpath = folders.length >= 2
|
||||||
|
? folders.slice(-(folders.length - 1)).join('/')
|
||||||
|
: folders.pop()
|
||||||
|
|
||||||
|
const _upperLevel = folders.slice(1, -1)
|
||||||
|
const upperLevel = _upperLevel.length === 2
|
||||||
|
? ['.', ..._upperLevel].join('/')
|
||||||
|
: _upperLevel.join('/')
|
||||||
|
|
||||||
|
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/filebrowser/downloaded`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ subdir: relpath })
|
||||||
|
})
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
task,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(r) => files$.next(sub
|
||||||
|
? [{
|
||||||
|
isDirectory: true,
|
||||||
|
isVideo: false,
|
||||||
|
modTime: '',
|
||||||
|
name: '..',
|
||||||
|
path: upperLevel,
|
||||||
|
size: 0,
|
||||||
|
}, ...r.filter(f => f.name !== '')]
|
||||||
|
: r.filter(f => f.name !== '')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable$ = useMemo(() => files$.pipe(
|
||||||
|
combineLatestWith(selected$),
|
||||||
|
map(([data, selected]) => data.map(x => ({
|
||||||
|
...x,
|
||||||
|
selected: selected.includes(x.name)
|
||||||
|
}))),
|
||||||
|
share()
|
||||||
|
), [])
|
||||||
|
|
||||||
|
const selectable = useObservable(selectable$, [])
|
||||||
|
|
||||||
|
const addSelected = (name: string) => {
|
||||||
|
selected$.value.includes(name)
|
||||||
|
? selected$.next(selected$.value.filter(val => val !== name))
|
||||||
|
: selected$.next([...selected$.value, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFile = (entry: DirectoryEntry) => pipe(
|
||||||
|
ffetch(`${serverAddr}/filebrowser/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: entry.path,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => fetcher()
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const deleteSelected = () => {
|
||||||
|
Promise.all(selectable
|
||||||
|
.filter(entry => entry.selected)
|
||||||
|
.map(deleteFile)
|
||||||
|
).then(fetcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetcher()
|
||||||
|
}, [serverAddr])
|
||||||
|
|
||||||
|
const onFileClick = (path: string) => startTransition(() => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
|
||||||
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadFile = (path: string) => startTransition(() => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
|
||||||
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFolderClick = (path: string) => startTransition(() => {
|
||||||
|
fetcherSubfolder(path)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
maxWidth="xl"
|
||||||
|
sx={{ mt: 4, mb: 4, minHeight: '100%' }}
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<IconMenu
|
||||||
|
posX={menuPos.x}
|
||||||
|
posY={menuPos.y}
|
||||||
|
hide={!showMenu}
|
||||||
|
onDownload={() => {
|
||||||
|
if (currentFile) {
|
||||||
|
downloadFile(currentFile?.path)
|
||||||
|
setCurrentFile(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
if (currentFile) {
|
||||||
|
deleteFile(currentFile)
|
||||||
|
setCurrentFile(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Backdrop
|
||||||
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||||
|
open={!(files$.observed) || isPending}
|
||||||
|
>
|
||||||
|
<CircularProgress color="primary" />
|
||||||
|
</Backdrop>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||||
|
{selectable.length === 0 && i18n.t('noFilesFound')}
|
||||||
|
{selectable.map((file, idx) => (
|
||||||
|
<ListItem
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCurrentFile(file)
|
||||||
|
setMenuPos({ x: e.clientX, y: e.clientY })
|
||||||
|
setShowMenu(true)
|
||||||
|
}}
|
||||||
|
key={idx}
|
||||||
|
secondaryAction={
|
||||||
|
<div>
|
||||||
|
{!file.isDirectory && <Typography
|
||||||
|
variant="caption"
|
||||||
|
component="span"
|
||||||
|
>
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
{!file.isDirectory && <>
|
||||||
|
<Checkbox
|
||||||
|
edge="end"
|
||||||
|
checked={file.selected}
|
||||||
|
onChange={() => addSelected(file.name)}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
disablePadding
|
||||||
|
>
|
||||||
|
<ListItemButton onClick={
|
||||||
|
() => file.isDirectory
|
||||||
|
? onFolderClick(file.path)
|
||||||
|
: onFileClick(file.path)
|
||||||
|
}>
|
||||||
|
<ListItemIcon>
|
||||||
|
{file.isDirectory
|
||||||
|
? <FolderIcon />
|
||||||
|
: file.isVideo
|
||||||
|
? <VideoFileIcon />
|
||||||
|
: <InsertDriveFileIcon />
|
||||||
|
}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={file.name}
|
||||||
|
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel='archive actions'
|
||||||
|
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||||
|
icon={<SpeedDialIcon />}
|
||||||
|
>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<DeleteForeverIcon />}
|
||||||
|
tooltipTitle={i18n.t('deleteSelected')}
|
||||||
|
tooltipOpen
|
||||||
|
onClick={() => {
|
||||||
|
if (selected$.value.length > 0) {
|
||||||
|
setOpenDialog(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SpeedDial>
|
||||||
|
<Dialog
|
||||||
|
open={openDialog}
|
||||||
|
onClose={() => setOpenDialog(false)}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Are you sure?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText id="alert-dialog-description">
|
||||||
|
You're deleting:
|
||||||
|
</DialogContentText>
|
||||||
|
<ul>
|
||||||
|
{selected$.value.map((entry, idx) => (
|
||||||
|
<li key={idx}>{entry}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpenDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
deleteSelected()
|
||||||
|
setOpenDialog(false)
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
Ok
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconMenu: React.FC<{
|
||||||
|
posX: number
|
||||||
|
posY: number
|
||||||
|
hide: boolean
|
||||||
|
onDownload: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
|
||||||
|
return (
|
||||||
|
<Paper sx={{
|
||||||
|
width: 320,
|
||||||
|
maxWidth: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
top: posY,
|
||||||
|
left: posX,
|
||||||
|
display: hide ? 'none' : 'block',
|
||||||
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
|
}}>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem onClick={onDownload}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DownloadIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
Download
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onDelete}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DeleteForeverIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
Delete
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { interval } from 'rxjs'
|
import { interval } from 'rxjs'
|
||||||
|
import { rpcPollingTimeState } from '../atoms/rpc'
|
||||||
import LivestreamDialog from '../components/livestream/LivestreamDialog'
|
import LivestreamDialog from '../components/livestream/LivestreamDialog'
|
||||||
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
|
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
|
||||||
import NoLivestreams from '../components/livestream/NoLivestreams'
|
import NoLivestreams from '../components/livestream/NoLivestreams'
|
||||||
@@ -24,7 +26,9 @@ const LiveStreamMonitorView: React.FC = () => {
|
|||||||
const [progress, setProgress] = useState<LiveStreamProgress>()
|
const [progress, setProgress] = useState<LiveStreamProgress>()
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
useSubscription(interval(1000), () => {
|
const rpcPollingRate = useAtomValue(rpcPollingTimeState)
|
||||||
|
|
||||||
|
useSubscription(interval(rpcPollingRate), () => {
|
||||||
client
|
client
|
||||||
.progressLivestream()
|
.progressLivestream()
|
||||||
.then(r => setProgress(r.result))
|
.then(r => setProgress(r.result))
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function Login() {
|
|||||||
</Title>
|
</Title>
|
||||||
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
||||||
To configure authentication check the
|
To configure authentication check the
|
||||||
<a href='https://github.com/marcopeocchi/yt-dlp-web-ui/wiki/Authentication-methods'>wiki</a>.
|
<a href='https://github.com/marcopiovanello/yt-dlp-web-ui/wiki/Authentication-methods'>wiki</a>.
|
||||||
</Title>
|
</Title>
|
||||||
<TextField
|
<TextField
|
||||||
label="Username"
|
label="Username"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Container,
|
Container,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -18,7 +17,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
capitalize
|
capitalize
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Suspense, useEffect, useMemo, useState } from 'react'
|
import { useAtom } from 'jotai'
|
||||||
|
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Subject,
|
Subject,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -28,11 +28,15 @@ import {
|
|||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
import { rpcPollingTimeState } from '../atoms/rpc'
|
import { rpcPollingTimeState } from '../atoms/rpc'
|
||||||
import {
|
import {
|
||||||
|
Accent,
|
||||||
Language,
|
Language,
|
||||||
Theme,
|
Theme,
|
||||||
|
accentState,
|
||||||
|
accents,
|
||||||
appTitleState,
|
appTitleState,
|
||||||
enableCustomArgsState,
|
enableCustomArgsState,
|
||||||
fileRenamingState,
|
fileRenamingState,
|
||||||
|
autoFileExtensionState,
|
||||||
formatSelectionState,
|
formatSelectionState,
|
||||||
languageState,
|
languageState,
|
||||||
languages,
|
languages,
|
||||||
@@ -44,11 +48,11 @@ import {
|
|||||||
themeState
|
themeState
|
||||||
} from '../atoms/settings'
|
} from '../atoms/settings'
|
||||||
import CookiesTextField from '../components/CookiesTextField'
|
import CookiesTextField from '../components/CookiesTextField'
|
||||||
|
import UpdateBinaryButton from '../components/UpdateBinaryButton'
|
||||||
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 Translator from '../lib/i18n'
|
||||||
import { validateDomain, validateIP } from '../utils'
|
import { validateDomain, validateIP } from '../utils'
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
|
|
||||||
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
|
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
@@ -58,6 +62,7 @@ export default function Settings() {
|
|||||||
const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
|
const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
|
||||||
const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
|
const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
|
||||||
const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
|
const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
|
||||||
|
const [autoFileExtension, setAutoFileExtension] = useAtom(autoFileExtensionState)
|
||||||
const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
|
const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
|
||||||
|
|
||||||
const [serverAddr, setServerAddr] = useAtom(serverAddressState)
|
const [serverAddr, setServerAddr] = useAtom(serverAddressState)
|
||||||
@@ -66,13 +71,13 @@ export default function Settings() {
|
|||||||
const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState)
|
const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState)
|
||||||
const [language, setLanguage] = useAtom(languageState)
|
const [language, setLanguage] = useAtom(languageState)
|
||||||
const [appTitle, setApptitle] = useAtom(appTitleState)
|
const [appTitle, setApptitle] = useAtom(appTitleState)
|
||||||
|
const [accent, setAccent] = useAtom(accentState)
|
||||||
|
|
||||||
const [theme, setTheme] = useAtom(themeState)
|
const [theme, setTheme] = useAtom(themeState)
|
||||||
|
|
||||||
const [invalidIP, setInvalidIP] = useState(false)
|
const [invalidIP, setInvalidIP] = useState(false)
|
||||||
|
|
||||||
const { i18n } = useI18n()
|
const { i18n } = useI18n()
|
||||||
const { client } = useRPC()
|
|
||||||
|
|
||||||
const { pushMessage } = useToast()
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
@@ -80,6 +85,9 @@ export default function Settings() {
|
|||||||
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
||||||
const serverPort$ = useMemo(() => new Subject<string>(), [])
|
const serverPort$ = useMemo(() => new Subject<string>(), [])
|
||||||
|
|
||||||
|
const [, updateState] = useState({})
|
||||||
|
const forceUpdate = useCallback(() => updateState({}), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sub = baseURL$
|
const sub = baseURL$
|
||||||
.pipe(debounceTime(500))
|
.pipe(debounceTime(500))
|
||||||
@@ -131,6 +139,11 @@ export default function Settings() {
|
|||||||
*/
|
*/
|
||||||
const handleLanguageChange = (event: SelectChangeEvent<Language>) => {
|
const handleLanguageChange = (event: SelectChangeEvent<Language>) => {
|
||||||
setLanguage(event.target.value as Language)
|
setLanguage(event.target.value as Language)
|
||||||
|
|
||||||
|
Translator.instance.setLanguage(event.target.value)
|
||||||
|
setTimeout(() => {
|
||||||
|
forceUpdate()
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,13 +153,6 @@ export default function Settings() {
|
|||||||
setTheme(event.target.value as Theme)
|
setTheme(event.target.value as Theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates yt-dlp binary via RPC
|
|
||||||
*/
|
|
||||||
const updateBinary = () => {
|
|
||||||
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||||
<Paper
|
<Paper
|
||||||
@@ -185,7 +191,6 @@ export default function Settings() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={12}>
|
<Grid item xs={12} md={12}>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={reverseProxy}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n.t('appTitle')}
|
label={i18n.t('appTitle')}
|
||||||
defaultValue={appTitle}
|
defaultValue={appTitle}
|
||||||
@@ -257,7 +262,7 @@ export default function Settings() {
|
|||||||
Appearance
|
Appearance
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12}>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
|
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -281,15 +286,31 @@ export default function Settings() {
|
|||||||
label={i18n.t('themeSelect')}
|
label={i18n.t('themeSelect')}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
>
|
>
|
||||||
<MenuItem value="light">Light</MenuItem>
|
<MenuItem value="light">{i18n.t('lightThemeButton')}</MenuItem>
|
||||||
<MenuItem value="dark">Dark</MenuItem>
|
<MenuItem value="dark">{i18n.t('darkThemeButton')}</MenuItem>
|
||||||
<MenuItem value="system">System</MenuItem>
|
<MenuItem value="system">System</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>{i18n.t('accentSelect')}</InputLabel>
|
||||||
|
<Select
|
||||||
|
defaultValue={accent}
|
||||||
|
label={i18n.t('accentSelect')}
|
||||||
|
onChange={(e) => setAccent(e.target.value as Accent)}
|
||||||
|
>
|
||||||
|
{accents.map((accent) => (
|
||||||
|
<MenuItem key={accent} value={accent}>
|
||||||
|
{capitalize(accent)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
|
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
|
||||||
General download settings
|
{i18n.t('generalDownloadSettings')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -324,12 +345,30 @@ export default function Settings() {
|
|||||||
<Switch
|
<Switch
|
||||||
defaultChecked={fileRenaming}
|
defaultChecked={fileRenaming}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
|
if (fileRenaming) {
|
||||||
|
setAutoFileExtension(false)
|
||||||
|
}
|
||||||
setFileRenaming(state => !state)
|
setFileRenaming(state => !state)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={i18n.t('filenameOverrideOption')}
|
label={i18n.t('filenameOverrideOption')}
|
||||||
/>
|
/>
|
||||||
|
{
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
disabled={!fileRenaming}
|
||||||
|
checked={fileRenaming ? autoFileExtension : false}
|
||||||
|
defaultChecked={autoFileExtension}
|
||||||
|
onChange={() => {
|
||||||
|
setAutoFileExtension(state => !state)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={i18n.t('autoFileExtensionOption')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -352,14 +391,8 @@ export default function Settings() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Stack direction="row">
|
<Stack direction="row" sx={{ pt: 2 }}>
|
||||||
<Button
|
<UpdateBinaryButton />
|
||||||
sx={{ mr: 1, mt: 3 }}
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => updateBinary()}
|
|
||||||
>
|
|
||||||
{i18n.t('updateBinButton')}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
157
frontend/src/views/Subscriptions.tsx
Normal file
157
frontend/src/views/Subscriptions.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Table, TableBody, TableCell, TableContainer,
|
||||||
|
TableHead, TablePagination, TableRow
|
||||||
|
} from '@mui/material'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||||
|
import SubscriptionsDialog from '../components/subscriptions/SubscriptionsDialog'
|
||||||
|
import SubscriptionsEditDialog from '../components/subscriptions/SubscriptionsEditDialog'
|
||||||
|
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import useFetch from '../hooks/useFetch'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { Subscription } from '../services/subscriptions'
|
||||||
|
import { PaginatedResponse } from '../types'
|
||||||
|
|
||||||
|
const SubscriptionsView: React.FC = () => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const [selectedSubscription, setSelectedSubscription] = useState<Subscription>()
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const [startId, setStartId] = useState(0)
|
||||||
|
const [limit, setLimit] = useState(9)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const { data: subs, fetcher: refecth } = useFetch<PaginatedResponse<Subscription[]>>(
|
||||||
|
`/subscriptions?id=${startId}&limit=${limit}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const deleteSubscription = async (id: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/subscriptions/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
() => refecth()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingBackdrop isLoading={!subs || isPending} />
|
||||||
|
|
||||||
|
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||||
|
|
||||||
|
<SubscriptionsEditDialog
|
||||||
|
subscription={selectedSubscription}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedSubscription(undefined)
|
||||||
|
refecth()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SubscriptionsDialog open={openDialog} onClose={() => {
|
||||||
|
setOpenDialog(s => !s)
|
||||||
|
refecth()
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{!subs || subs.data.length === 0 ?
|
||||||
|
<NoSubscriptions /> :
|
||||||
|
<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 align="left">URL</TableCell>
|
||||||
|
<TableCell align="right">Params</TableCell>
|
||||||
|
<TableCell align="right">{i18n.t('cronExpressionLabel')}</TableCell>
|
||||||
|
<TableCell align="center">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody sx={{ mb: 'auto' }}>
|
||||||
|
{subs.data.map(x => (
|
||||||
|
<TableRow
|
||||||
|
key={x.id}
|
||||||
|
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||||
|
>
|
||||||
|
<TableCell>{x.url}</TableCell>
|
||||||
|
<TableCell align='right'>
|
||||||
|
{x.params}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align='right'>
|
||||||
|
{x.cron_expression}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align='center'>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
size='small'
|
||||||
|
sx={{ mr: 0.5 }}
|
||||||
|
onClick={() => setSelectedSubscription(x)}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
size='small'
|
||||||
|
onClick={() => startTransition(async () => await deleteSubscription(x.id))}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={-1}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_, p) => {
|
||||||
|
if (p < page) {
|
||||||
|
setPage(s => (s - 1 <= 0 ? 0 : s - 1))
|
||||||
|
setStartId(subs.first)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPage(s => s + 1)
|
||||||
|
setStartId(subs.next)
|
||||||
|
}}
|
||||||
|
rowsPerPage={limit}
|
||||||
|
rowsPerPageOptions={[9, 10, 25, 50, 100]}
|
||||||
|
onRowsPerPageChange={(e) => { setLimit(parseInt(e.target.value)) }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Container>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsView
|
||||||
77
frontend/src/views/Twitch.tsx
Normal file
77
frontend/src/views/Twitch.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Chip,
|
||||||
|
Container,
|
||||||
|
Paper
|
||||||
|
} from '@mui/material'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||||
|
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||||
|
import TwitchDialog from '../components/twitch/TwitchDialog'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import useFetch from '../hooks/useFetch'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
|
||||||
|
const TwitchView: React.FC = () => {
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const { data: users, fetcher: refetch } = useFetch<Array<string>>('/twitch/users')
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const deleteUser = async (user: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/twitch/user/${user}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
() => refetch()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingBackdrop isLoading={!users || isPending} />
|
||||||
|
|
||||||
|
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||||
|
|
||||||
|
<TwitchDialog open={openDialog} onClose={() => {
|
||||||
|
setOpenDialog(s => !s)
|
||||||
|
refetch()
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{
|
||||||
|
!users || users.length === 0 ?
|
||||||
|
<NoSubscriptions /> :
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||||
|
<Paper sx={{
|
||||||
|
p: 2.5,
|
||||||
|
minHeight: '80vh',
|
||||||
|
}}>
|
||||||
|
{users.map(user => (
|
||||||
|
<Chip
|
||||||
|
label={user}
|
||||||
|
onDelete={() => startTransition(async () => await deleteUser(user))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchView
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react-swc'
|
||||||
import million from 'million/compiler'
|
|
||||||
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(() => {
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
million.vite({ auto: true }),
|
|
||||||
react(),
|
react(),
|
||||||
ViteYaml(),
|
ViteYaml(),
|
||||||
],
|
],
|
||||||
54
go.mod
54
go.mod
@@ -1,37 +1,35 @@
|
|||||||
module github.com/marcopeocchi/yt-dlp-web-ui/v3
|
module github.com/marcopiovanello/yt-dlp-web-ui/v3
|
||||||
|
|
||||||
go 1.23
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
github.com/coreos/go-oidc/v3 v3.15.0
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
golang.org/x/oauth2 v0.23.0
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
golang.org/x/sync v0.8.0
|
github.com/spf13/viper v1.20.1
|
||||||
golang.org/x/sys v0.25.0
|
go.etcd.io/bbolt v1.4.3
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
golang.org/x/crypto v0.41.0
|
||||||
modernc.org/sqlite v1.33.1
|
golang.org/x/oauth2 v0.30.0
|
||||||
|
golang.org/x/sys v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/kr/pretty v0.1.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/spf13/afero v1.12.0 // indirect
|
||||||
golang.org/x/crypto v0.27.0 // indirect
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/strutil v1.2.0 // indirect
|
|
||||||
modernc.org/token v1.1.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
137
go.sum
137
go.sum
@@ -1,87 +1,76 @@
|
|||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
|
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
|
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
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/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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
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-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
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=
|
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
|
||||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
|
||||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
|
||||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
|
||||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
|
||||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
|
||||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
|
|||||||
161
main.go
161
main.go
@@ -1,111 +1,102 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"flag"
|
"flag"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/cli"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
//go:embed frontend/dist/index.html
|
||||||
host string
|
//go:embed frontend/dist/assets/*
|
||||||
port int
|
var frontend embed.FS
|
||||||
queueSize int
|
|
||||||
configFile string
|
|
||||||
downloadPath string
|
|
||||||
downloaderPath string
|
|
||||||
sessionFilePath string
|
|
||||||
localDatabasePath string
|
|
||||||
|
|
||||||
requireAuth bool
|
//go:embed openapi/*
|
||||||
username string
|
var swagger embed.FS
|
||||||
password string
|
|
||||||
|
|
||||||
userFromEnv = os.Getenv("USERNAME")
|
|
||||||
passFromEnv = os.Getenv("PASSWORD")
|
|
||||||
|
|
||||||
logFile string
|
|
||||||
enableFileLogging bool
|
|
||||||
|
|
||||||
//go:embed frontend/dist/index.html
|
|
||||||
//go:embed frontend/dist/assets/*
|
|
||||||
frontend embed.FS
|
|
||||||
|
|
||||||
//go:embed openapi/*
|
|
||||||
swagger embed.FS
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at")
|
|
||||||
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
|
|
||||||
flag.IntVar(&queueSize, "qs", 2, "Queue size (concurrent downloads)")
|
|
||||||
|
|
||||||
flag.StringVar(&configFile, "conf", "./config.yml", "Config file path")
|
|
||||||
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
|
|
||||||
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
|
|
||||||
flag.StringVar(&sessionFilePath, "session", ".", "session file path")
|
|
||||||
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
|
|
||||||
|
|
||||||
flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file")
|
|
||||||
flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location")
|
|
||||||
|
|
||||||
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
|
|
||||||
flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
|
|
||||||
flag.StringVar(&password, "pass", passFromEnv, "Password required for auth")
|
|
||||||
|
|
||||||
flag.Parse()
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
frontend, err := fs.Sub(frontend, "frontend/dist")
|
// Parse optional config path from flag
|
||||||
if err != nil {
|
var configFile string
|
||||||
log.Fatalln(err)
|
flag.StringVar(&configFile, "conf", "./config.yml", "Config file path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
v := viper.New()
|
||||||
|
v.SetConfigFile(configFile)
|
||||||
|
v.SetConfigType("yaml")
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
v.SetDefault("server.host", "0.0.0.0")
|
||||||
|
v.SetDefault("server.port", 3033)
|
||||||
|
v.SetDefault("server.queue_size", 2)
|
||||||
|
v.SetDefault("paths.download_path", ".")
|
||||||
|
v.SetDefault("paths.downloader_path", "yt-dlp")
|
||||||
|
v.SetDefault("paths.local_database_path", ".")
|
||||||
|
v.SetDefault("logging.log_path", "yt-dlp-webui.log")
|
||||||
|
v.SetDefault("logging.enable_file_logging", false)
|
||||||
|
v.SetDefault("authentication.require_auth", false)
|
||||||
|
|
||||||
|
// Env binding
|
||||||
|
v.SetEnvPrefix("APP")
|
||||||
|
v.AutomaticEnv()
|
||||||
|
|
||||||
|
// Load YAML file if exists
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
slog.Debug("using defaults")
|
||||||
}
|
}
|
||||||
|
|
||||||
c := config.Instance()
|
cfg := config.Instance()
|
||||||
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
{
|
slog.Error("failed to load config", "error", err)
|
||||||
// init the config struct with the values from flags
|
|
||||||
// TODO: find an alternative way to populate the config struct from flags or config file
|
|
||||||
c.Host = host
|
|
||||||
c.Port = port
|
|
||||||
|
|
||||||
c.QueueSize = queueSize
|
|
||||||
|
|
||||||
c.DownloadPath = downloadPath
|
|
||||||
c.DownloaderPath = downloaderPath
|
|
||||||
c.SessionFilePath = sessionFilePath
|
|
||||||
c.LocalDatabasePath = localDatabasePath
|
|
||||||
|
|
||||||
c.LogPath = logFile
|
|
||||||
c.EnableFileLogging = enableFileLogging
|
|
||||||
|
|
||||||
c.RequireAuth = requireAuth
|
|
||||||
c.Username = username
|
|
||||||
c.Password = password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// limit concurrent downloads for systems with 2 or less logical cores
|
if cfg.Server.QueueSize <= 0 || runtime.NumCPU() <= 2 {
|
||||||
if runtime.NumCPU() <= 2 {
|
cfg.Server.QueueSize = 2
|
||||||
c.QueueSize = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if config file is found it will be merged with the current config struct
|
// 6. Frontend FS
|
||||||
if err := c.LoadFile(configFile); err != nil {
|
var appFS fs.FS
|
||||||
log.Println(cli.BgRed, "config", cli.Reset, err)
|
if fp := v.GetString("frontend_path"); fp != "" {
|
||||||
|
appFS = os.DirFS(fp)
|
||||||
|
} else {
|
||||||
|
sub, err := fs.Sub(frontend, "frontend/dist")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load embedded frontend", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
appFS = sub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure OpenID if needed
|
||||||
openid.Configure()
|
openid.Configure()
|
||||||
|
|
||||||
server.RunBlocking(&server.RunConfig{
|
// Graceful shutdown
|
||||||
App: frontend,
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
slog.Info("starting server",
|
||||||
|
"host", cfg.Server.Host,
|
||||||
|
"port", cfg.Server.Port,
|
||||||
|
"queue_size", cfg.Server.QueueSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := server.Run(ctx, &server.RunConfig{
|
||||||
|
App: appFS,
|
||||||
Swagger: swagger,
|
Swagger: swagger,
|
||||||
})
|
}); err != nil {
|
||||||
|
slog.Error("server stopped with error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("server exited cleanly")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
version = "v3.1.2";
|
version = "v3.1.2";
|
||||||
meta = {
|
meta = {
|
||||||
description = "A terrible web ui for yt-dlp. Designed to be self-hosted.";
|
description = "A terrible web ui for yt-dlp. Designed to be self-hosted.";
|
||||||
homepage = "https://github.com/marcopeocchi/yt-dlp-web-ui";
|
homepage = "https://github.com/marcopiovanello/yt-dlp-web-ui";
|
||||||
license = lib.licenses.mpl20;
|
license = lib.licenses.mpl20;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Swagger yt-dlp-webui - OpenAPI 3.1",
|
"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)",
|
"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/marcopiovanello/yt-dlp-web-ui)",
|
||||||
"termsOfService": "http://swagger.io/terms/",
|
"termsOfService": "http://swagger.io/terms/",
|
||||||
"contact": {
|
"contact": {
|
||||||
"email": "apiteam@swagger.io"
|
"email": "apiteam@swagger.io"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"description": "Everything about your Pets",
|
"description": "Everything about your Pets",
|
||||||
"externalDocs": {
|
"externalDocs": {
|
||||||
"description": "Find out more",
|
"description": "Find out more",
|
||||||
"url": "https://github.com/marcopeocchi/yt-dlp-web-ui"
|
"url": "https://github.com/marcopiovanello/yt-dlp-web-ui"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
18
server/archive/archive.go
Normal file
18
server/archive/archive.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// alias type
|
||||||
|
// TODO: remove after refactoring
|
||||||
|
type Service = domain.Service
|
||||||
|
type Entity = domain.ArchiveEntry
|
||||||
|
|
||||||
|
func ApplyRouter(db *sql.DB) func(chi.Router) {
|
||||||
|
handler, _ := Container(db)
|
||||||
|
return handler.ApplyRouter()
|
||||||
|
}
|
||||||
16
server/archive/container.go
Normal file
16
server/archive/container.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Container(db *sql.DB) (domain.RestHandler, domain.Service) {
|
||||||
|
var (
|
||||||
|
r = provideRepository(db)
|
||||||
|
s = provideService(r)
|
||||||
|
h = provideHandler(s)
|
||||||
|
)
|
||||||
|
return h, s
|
||||||
|
}
|
||||||
13
server/archive/data/models.go
Normal file
13
server/archive/data/models.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ArchiveEntry struct {
|
||||||
|
Id string
|
||||||
|
Title string
|
||||||
|
Path string
|
||||||
|
Thumbnail string
|
||||||
|
Source string
|
||||||
|
Metadata string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
51
server/archive/domain/archive.go
Normal file
51
server/archive/domain/archive.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchiveEntry struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedResponse[T any] struct {
|
||||||
|
First int64 `json:"first"`
|
||||||
|
Next int64 `json:"next"`
|
||||||
|
Data T `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
Archive(ctx context.Context, model *data.ArchiveEntry) error
|
||||||
|
SoftDelete(ctx context.Context, id string) (*data.ArchiveEntry, error)
|
||||||
|
HardDelete(ctx context.Context, id string) (*data.ArchiveEntry, error)
|
||||||
|
List(ctx context.Context, startRowId int, limit int) (*[]data.ArchiveEntry, error)
|
||||||
|
GetCursor(ctx context.Context, id string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
Archive(ctx context.Context, entity *ArchiveEntry) error
|
||||||
|
SoftDelete(ctx context.Context, id string) (*ArchiveEntry, error)
|
||||||
|
HardDelete(ctx context.Context, id string) (*ArchiveEntry, error)
|
||||||
|
List(ctx context.Context, startRowId int, limit int) (*PaginatedResponse[[]ArchiveEntry], error)
|
||||||
|
GetCursor(ctx context.Context, id string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RestHandler interface {
|
||||||
|
List() http.HandlerFunc
|
||||||
|
Archive() http.HandlerFunc
|
||||||
|
SoftDelete() http.HandlerFunc
|
||||||
|
HardDelete() http.HandlerFunc
|
||||||
|
GetCursor() http.HandlerFunc
|
||||||
|
ApplyRouter() func(chi.Router)
|
||||||
|
}
|
||||||
42
server/archive/provider.go
Normal file
42
server/archive/provider.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/repository"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/rest"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
repo domain.Repository
|
||||||
|
svc domain.Service
|
||||||
|
hand domain.RestHandler
|
||||||
|
|
||||||
|
repoOnce sync.Once
|
||||||
|
svcOnce sync.Once
|
||||||
|
handOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func provideRepository(db *sql.DB) domain.Repository {
|
||||||
|
repoOnce.Do(func() {
|
||||||
|
repo = repository.New(db)
|
||||||
|
})
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func provideService(r domain.Repository) domain.Service {
|
||||||
|
svcOnce.Do(func() {
|
||||||
|
svc = service.New(r)
|
||||||
|
})
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func provideHandler(s domain.Service) domain.RestHandler {
|
||||||
|
handOnce.Do(func() {
|
||||||
|
hand = rest.New(s)
|
||||||
|
})
|
||||||
|
return hand
|
||||||
|
}
|
||||||
156
server/archive/repository/repository.go
Normal file
156
server/archive/repository/repository.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB) domain.Repository {
|
||||||
|
return &Repository{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Archive(ctx context.Context, entry *data.ArchiveEntry) error {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"INSERT INTO archive (id, title, path, thumbnail, source, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
uuid.NewString(),
|
||||||
|
entry.Title,
|
||||||
|
entry.Path,
|
||||||
|
entry.Thumbnail,
|
||||||
|
entry.Source,
|
||||||
|
entry.Metadata,
|
||||||
|
entry.CreatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SoftDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var model data.ArchiveEntry
|
||||||
|
|
||||||
|
row := tx.QueryRowContext(ctx, "SELECT * FROM archive WHERE id = ?", id)
|
||||||
|
|
||||||
|
if err := row.Scan(
|
||||||
|
&model.Id,
|
||||||
|
&model.Title,
|
||||||
|
&model.Path,
|
||||||
|
&model.Thumbnail,
|
||||||
|
&model.Source,
|
||||||
|
&model.Metadata,
|
||||||
|
&model.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM archive WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) HardDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) {
|
||||||
|
entry, err := r.SoftDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(entry.Path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) List(ctx context.Context, startRowId int, limit int) (*[]data.ArchiveEntry, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
var entries []data.ArchiveEntry
|
||||||
|
|
||||||
|
// cursor based pagination
|
||||||
|
rows, err := conn.QueryContext(ctx, "SELECT rowid, * FROM archive WHERE rowid > ? LIMIT ?", startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var rowId int64
|
||||||
|
var entry data.ArchiveEntry
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&rowId,
|
||||||
|
&entry.Id,
|
||||||
|
&entry.Title,
|
||||||
|
&entry.Path,
|
||||||
|
&entry.Thumbnail,
|
||||||
|
&entry.Source,
|
||||||
|
&entry.Metadata,
|
||||||
|
&entry.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return &entries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetCursor(ctx context.Context, id string) (int64, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
row := conn.QueryRowContext(ctx, "SELECT rowid FROM archive WHERE id = ?", id)
|
||||||
|
|
||||||
|
var rowId int64
|
||||||
|
|
||||||
|
if err := row.Scan(&rowId); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowId, nil
|
||||||
|
}
|
||||||
162
server/archive/rest/handler.go
Normal file
162
server/archive/rest/handler.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
|
||||||
|
|
||||||
|
middlewares "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service domain.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(service domain.Service) domain.RestHandler {
|
||||||
|
return &Handler{
|
||||||
|
service: service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements domain.RestHandler.
|
||||||
|
func (h *Handler) List() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
var (
|
||||||
|
startRowIdParam = r.URL.Query().Get("id")
|
||||||
|
LimitParam = r.URL.Query().Get("limit")
|
||||||
|
)
|
||||||
|
|
||||||
|
startRowId, err := strconv.Atoi(startRowIdParam)
|
||||||
|
if err != nil {
|
||||||
|
startRowId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := strconv.Atoi(LimitParam)
|
||||||
|
if err != nil {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.service.List(r.Context(), startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive implements domain.RestHandler.
|
||||||
|
func (h *Handler) Archive() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
var req domain.ArchiveEntry
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.service.Archive(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode("ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete implements domain.RestHandler.
|
||||||
|
func (h *Handler) HardDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
res, err := h.service.HardDelete(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete implements domain.RestHandler.
|
||||||
|
func (h *Handler) SoftDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
res, err := h.service.SoftDelete(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCursor implements domain.RestHandler.
|
||||||
|
func (h *Handler) GetCursor() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
cursorId, err := h.service.GetCursor(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(cursorId); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRouter implements domain.RestHandler.
|
||||||
|
func (h *Handler) ApplyRouter() func(chi.Router) {
|
||||||
|
return func(r chi.Router) {
|
||||||
|
if config.Instance().Authentication.RequireAuth {
|
||||||
|
r.Use(middlewares.Authenticated)
|
||||||
|
}
|
||||||
|
if config.Instance().OpenId.UseOpenId {
|
||||||
|
r.Use(openid.Middleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Get("/", h.List())
|
||||||
|
r.Get("/cursor/{id}", h.GetCursor())
|
||||||
|
r.Post("/", h.Archive())
|
||||||
|
r.Delete("/soft/{id}", h.SoftDelete())
|
||||||
|
r.Delete("/hard/{id}", h.HardDelete())
|
||||||
|
}
|
||||||
|
}
|
||||||
121
server/archive/service/service.go
Normal file
121
server/archive/service/service.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository domain.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository domain.Repository) domain.Service {
|
||||||
|
return &Service{
|
||||||
|
repository: repository,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive implements domain.Service.
|
||||||
|
func (s *Service) Archive(ctx context.Context, entity *domain.ArchiveEntry) error {
|
||||||
|
return s.repository.Archive(ctx, &data.ArchiveEntry{
|
||||||
|
Id: entity.Id,
|
||||||
|
Title: entity.Title,
|
||||||
|
Path: entity.Path,
|
||||||
|
Thumbnail: entity.Thumbnail,
|
||||||
|
Source: entity.Source,
|
||||||
|
Metadata: entity.Metadata,
|
||||||
|
CreatedAt: entity.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete implements domain.Service.
|
||||||
|
func (s *Service) HardDelete(ctx context.Context, id string) (*domain.ArchiveEntry, error) {
|
||||||
|
res, err := s.repository.HardDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.ArchiveEntry{
|
||||||
|
Id: res.Id,
|
||||||
|
Title: res.Title,
|
||||||
|
Path: res.Path,
|
||||||
|
Thumbnail: res.Thumbnail,
|
||||||
|
Source: res.Source,
|
||||||
|
Metadata: res.Metadata,
|
||||||
|
CreatedAt: res.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete implements domain.Service.
|
||||||
|
func (s *Service) SoftDelete(ctx context.Context, id string) (*domain.ArchiveEntry, error) {
|
||||||
|
res, err := s.repository.SoftDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.ArchiveEntry{
|
||||||
|
Id: res.Id,
|
||||||
|
Title: res.Title,
|
||||||
|
Path: res.Path,
|
||||||
|
Thumbnail: res.Thumbnail,
|
||||||
|
Source: res.Source,
|
||||||
|
Metadata: res.Metadata,
|
||||||
|
CreatedAt: res.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements domain.Service.
|
||||||
|
func (s *Service) List(
|
||||||
|
ctx context.Context,
|
||||||
|
startRowId int,
|
||||||
|
limit int,
|
||||||
|
) (*domain.PaginatedResponse[[]domain.ArchiveEntry], error) {
|
||||||
|
res, err := s.repository.List(ctx, startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entities := make([]domain.ArchiveEntry, len(*res))
|
||||||
|
|
||||||
|
for i, model := range *res {
|
||||||
|
entities[i] = domain.ArchiveEntry{
|
||||||
|
Id: model.Id,
|
||||||
|
Title: model.Title,
|
||||||
|
Path: model.Path,
|
||||||
|
Thumbnail: model.Thumbnail,
|
||||||
|
Source: model.Source,
|
||||||
|
Metadata: model.Metadata,
|
||||||
|
CreatedAt: model.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
first int64
|
||||||
|
next int64
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(entities) > 0 {
|
||||||
|
first, err = s.repository.GetCursor(ctx, entities[0].Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err = s.repository.GetCursor(ctx, entities[len(entities)-1].Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.PaginatedResponse[[]domain.ArchiveEntry]{
|
||||||
|
First: first,
|
||||||
|
Next: next,
|
||||||
|
Data: entities,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCursor implements domain.Service.
|
||||||
|
func (s *Service) GetCursor(ctx context.Context, id string) (int64, error) {
|
||||||
|
return s.repository.GetCursor(ctx, id)
|
||||||
|
}
|
||||||
58
server/archive/utils.go
Normal file
58
server/archive/utils.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Perform a search on the archive.txt file an determines if a download
|
||||||
|
// has already be done.
|
||||||
|
func DownloadExists(ctx context.Context, url string) (bool, error) {
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
config.Instance().Paths.DownloaderPath,
|
||||||
|
"--print",
|
||||||
|
"%(extractor)s %(id)s",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extractorAndURL := bytes.Trim(stdout, "\n")
|
||||||
|
|
||||||
|
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "archive.txt"))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer fd.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(fd)
|
||||||
|
|
||||||
|
// search linearly for lower memory usage...
|
||||||
|
// the a pre-sorted with hashed values version of the archive.txt file can be loaded in memory
|
||||||
|
// and perform a binary search on it.
|
||||||
|
for scanner.Scan() {
|
||||||
|
if bytes.Equal(scanner.Bytes(), extractorAndURL) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// data, err := io.ReadAll(fd)
|
||||||
|
// if err != nil {
|
||||||
|
// return false, err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// slices.BinarySearchFunc(data, extractorAndURL, func(a []byte, b []byte) int {
|
||||||
|
// return hash(a).Compare(hash(b))
|
||||||
|
// })
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
41
server/archiver/archiver.go
Normal file
41
server/archiver/archiver.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package archiver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ch = make(chan *Message, 1)
|
||||||
|
archiveService archive.Service
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message = archive.Entity
|
||||||
|
|
||||||
|
func Register(db *sql.DB) {
|
||||||
|
_, s := archive.Container(db)
|
||||||
|
archiveService = s
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
go func() {
|
||||||
|
for m := range ch {
|
||||||
|
slog.Info(
|
||||||
|
"archiving completed download",
|
||||||
|
slog.String("title", m.Title),
|
||||||
|
slog.String("source", m.Source),
|
||||||
|
)
|
||||||
|
archiveService.Archive(context.Background(), m)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Publish(m *Message) {
|
||||||
|
if config.Instance().AutoArchive {
|
||||||
|
ch <- m
|
||||||
|
}
|
||||||
|
}
|
||||||
18
server/common/types.go
Normal file
18
server/common/types.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Used to deser the yt-dlp -J output
|
||||||
|
type DownloadMetadata struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
Size int32 `json:"filesize_approx"`
|
||||||
|
VCodec string `json:"vcodec"`
|
||||||
|
ACodec string `json:"acodec"`
|
||||||
|
Extension string `json:"ext"`
|
||||||
|
OriginalURL string `json:"original_url"`
|
||||||
|
FileName string `json:"filename"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -1,34 +1,64 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
|
Paths PathsConfig `yaml:"paths"`
|
||||||
|
Authentication AuthConfig `yaml:"authentication"`
|
||||||
|
OpenId OpenIdConfig `yaml:"openid"`
|
||||||
|
Frontend FrontendConfig `yaml:"frontend"`
|
||||||
|
AutoArchive bool `yaml:"auto_archive"`
|
||||||
|
Twitch TwitchConfig `yaml:"twitch"`
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
QueueSize int `yaml:"queue_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingConfig struct {
|
||||||
LogPath string `yaml:"log_path"`
|
LogPath string `yaml:"log_path"`
|
||||||
EnableFileLogging bool `yaml:"enable_file_logging"`
|
EnableFileLogging bool `yaml:"enable_file_logging"`
|
||||||
BaseURL string `yaml:"base_url"`
|
}
|
||||||
Host string `yaml:"host"`
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
DownloadPath string `yaml:"downloadPath"`
|
|
||||||
DownloaderPath string `yaml:"downloaderPath"`
|
|
||||||
RequireAuth bool `yaml:"require_auth"`
|
|
||||||
Username string `yaml:"username"`
|
|
||||||
Password string `yaml:"password"`
|
|
||||||
QueueSize int `yaml:"queue_size"`
|
|
||||||
LocalDatabasePath string `yaml:"local_database_path"`
|
|
||||||
SessionFilePath string `yaml:"session_file_path"`
|
|
||||||
path string
|
|
||||||
|
|
||||||
UseOpenId bool `yaml:"use_openid"`
|
type PathsConfig struct {
|
||||||
OpenIdProviderURL string `yaml:"openid_provider_url"`
|
DownloadPath string `yaml:"download_path"`
|
||||||
OpenIdClientId string `yaml:"openid_client_id"`
|
DownloaderPath string `yaml:"downloader_path"`
|
||||||
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
LocalDatabasePath string `yaml:"local_database_path"`
|
||||||
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
RequireAuth bool `yaml:"require_auth"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
PasswordHash string `yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenIdConfig struct {
|
||||||
|
UseOpenId bool `yaml:"use_openid"`
|
||||||
|
ProviderURL string `yaml:"openid_provider_url"`
|
||||||
|
ClientId string `yaml:"openid_client_id"`
|
||||||
|
ClientSecret string `yaml:"openid_client_secret"`
|
||||||
|
RedirectURL string `yaml:"openid_redirect_url"`
|
||||||
|
EmailWhitelist []string `yaml:"openid_email_whitelist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrontendConfig struct {
|
||||||
|
FrontendPath string `yaml:"frontend_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwitchConfig struct {
|
||||||
|
ClientId string `yaml:"client_id"`
|
||||||
|
ClientSecret string `yaml:"client_secret"`
|
||||||
|
CheckInterval time.Duration `yaml:"check_interval"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -40,27 +70,12 @@ func Instance() *Config {
|
|||||||
if instance == nil {
|
if instance == nil {
|
||||||
instanceOnce.Do(func() {
|
instanceOnce.Do(func() {
|
||||||
instance = &Config{}
|
instance = &Config{}
|
||||||
|
instance.Twitch.CheckInterval = time.Minute * 5
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialises the Config struct given its config file
|
|
||||||
func (c *Config) LoadFile(filename string) error {
|
|
||||||
fd, err := os.Open(filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.path = filename
|
|
||||||
|
|
||||||
if err := yaml.NewDecoder(fd).Decode(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path of the directory containing the config file
|
// Path of the directory containing the config file
|
||||||
func (c *Config) Dir() string { return filepath.Dir(c.path) }
|
func (c *Config) Dir() string { return filepath.Dir(c.path) }
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock")
|
var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock")
|
||||||
@@ -34,6 +34,33 @@ func Migrate(ctx context.Context, db *sql.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`CREATE TABLE IF NOT EXISTS archive (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
path VARCHAR(255) NOT NULL,
|
||||||
|
thumbnail TEXT,
|
||||||
|
source VARCHAR(255),
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME
|
||||||
|
)`,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
url VARCHAR(2048) UNIQUE NOT NULL,
|
||||||
|
params TEXT NOT NULL,
|
||||||
|
cron TEXT
|
||||||
|
)`,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if lockFileExists() {
|
if lockFileExists() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handlers
|
package filebrowser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
@@ -17,8 +17,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal/kv"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -88,7 +89,7 @@ type ListRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListDownloaded(w http.ResponseWriter, r *http.Request) {
|
func ListDownloaded(w http.ResponseWriter, r *http.Request) {
|
||||||
root := config.Instance().DownloadPath
|
root := config.Instance().Paths.DownloadPath
|
||||||
req := new(ListRequest)
|
req := new(ListRequest)
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -156,7 +157,7 @@ func SendFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
filename := string(decoded)
|
filename := string(decoded)
|
||||||
|
|
||||||
root := config.Instance().DownloadPath
|
root := config.Instance().Paths.DownloadPath
|
||||||
|
|
||||||
if strings.Contains(filepath.Dir(filepath.Clean(filename)), filepath.Clean(root)) {
|
if strings.Contains(filepath.Dir(filepath.Clean(filename)), filepath.Clean(root)) {
|
||||||
http.ServeFile(w, r, filename)
|
http.ServeFile(w, r, filename)
|
||||||
@@ -188,7 +189,7 @@ func DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
filename := string(decoded)
|
filename := string(decoded)
|
||||||
|
|
||||||
root := config.Instance().DownloadPath
|
root := config.Instance().Paths.DownloadPath
|
||||||
|
|
||||||
if strings.Contains(filepath.Dir(filepath.Clean(filename)), filepath.Clean(root)) {
|
if strings.Contains(filepath.Dir(filepath.Clean(filename)), filepath.Clean(root)) {
|
||||||
w.Header().Add("Content-Disposition", "inline; filename=\""+filepath.Base(filename)+"\"")
|
w.Header().Add("Content-Disposition", "inline; filename=\""+filepath.Base(filename)+"\"")
|
||||||
@@ -207,9 +208,9 @@ func DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BulkDownload(mdb *internal.MemoryDB) http.HandlerFunc {
|
func BulkDownload(mdb *kv.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ps := slices.DeleteFunc(*mdb.All(), func(e internal.ProcessResponse) bool {
|
ps := slices.DeleteFunc(*mdb.All(), func(e internal.ProcessSnapshot) bool {
|
||||||
return e.Progress.Status != internal.StatusCompleted
|
return e.Progress.Status != internal.StatusCompleted
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseURL(url string) (*Metadata, error) {
|
func ParseURL(url string) (*Metadata, error) {
|
||||||
cmd := exec.Command(config.Instance().DownloaderPath, url, "-J")
|
cmd := exec.Command(config.Instance().Paths.DownloaderPath, url, "-J")
|
||||||
|
|
||||||
stdout, err := cmd.Output()
|
stdout, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ type Format struct {
|
|||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
VCodec string `json:"vcodec"`
|
VCodec string `json:"vcodec"`
|
||||||
ACodec string `json:"acodec"`
|
ACodec string `json:"acodec"`
|
||||||
Size float32 `json:"filesize_approx"`
|
Size float64 `json:"filesize_approx"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||||
|
)
|
||||||
|
|
||||||
// Used to unmarshall yt-dlp progress
|
// Used to unmarshall yt-dlp progress
|
||||||
type ProgressTemplate struct {
|
type ProgressTemplate struct {
|
||||||
Percentage string `json:"percentage"`
|
Percentage string `json:"percentage"`
|
||||||
Speed float32 `json:"speed"`
|
Speed float64 `json:"speed"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
Eta float32 `json:"eta"`
|
Eta float64 `json:"eta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostprocessTemplate struct {
|
type PostprocessTemplate struct {
|
||||||
@@ -25,39 +27,25 @@ type DownloadOutput struct {
|
|||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
Status int `json:"process_status"`
|
Status int `json:"process_status"`
|
||||||
Percentage string `json:"percentage"`
|
Percentage string `json:"percentage"`
|
||||||
Speed float32 `json:"speed"`
|
Speed float64 `json:"speed"`
|
||||||
ETA float32 `json:"eta"`
|
ETA float64 `json:"eta"`
|
||||||
}
|
|
||||||
|
|
||||||
// Used to deser the yt-dlp -J output
|
|
||||||
type DownloadInfo struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Thumbnail string `json:"thumbnail"`
|
|
||||||
Resolution string `json:"resolution"`
|
|
||||||
Size int32 `json:"filesize_approx"`
|
|
||||||
VCodec string `json:"vcodec"`
|
|
||||||
ACodec string `json:"acodec"`
|
|
||||||
Extension string `json:"ext"`
|
|
||||||
OriginalURL string `json:"original_url"`
|
|
||||||
FileName string `json:"filename"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// struct representing the response sent to the client
|
// struct representing the response sent to the client
|
||||||
// as JSON-RPC result field
|
// as JSON-RPC result field
|
||||||
type ProcessResponse struct {
|
type ProcessSnapshot struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Progress DownloadProgress `json:"progress"`
|
Progress DownloadProgress `json:"progress"`
|
||||||
Info DownloadInfo `json:"info"`
|
Info common.DownloadMetadata `json:"info"`
|
||||||
Output DownloadOutput `json:"output"`
|
Output DownloadOutput `json:"output"`
|
||||||
Params []string `json:"params"`
|
Params []string `json:"params"`
|
||||||
|
DownloaderName string `json:"downloader_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// struct representing the current status of the memoryDB
|
// struct representing the current status of the memoryDB
|
||||||
// used for serializaton/persistence reasons
|
// used for serializaton/persistence reasons
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Processes []ProcessResponse `json:"processes"`
|
Snapshots []ProcessSnapshot `json:"processes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// struct representing the intent to stop a specific process
|
// struct representing the intent to stop a specific process
|
||||||
@@ -85,3 +73,11 @@ type CustomTemplate struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending = iota
|
||||||
|
StatusDownloading
|
||||||
|
StatusCompleted
|
||||||
|
StatusErrored
|
||||||
|
StatusLiveStream
|
||||||
|
)
|
||||||
|
|||||||
42
server/internal/downloaders/common.go
Normal file
42
server/internal/downloaders/common.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package downloaders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloaderBase struct {
|
||||||
|
Id string
|
||||||
|
URL string
|
||||||
|
Metadata common.DownloadMetadata
|
||||||
|
Pending bool
|
||||||
|
Completed bool
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DownloaderBase) FetchMetadata(fetcher func(url string) (*common.DownloadMetadata, error)) {
|
||||||
|
d.mutex.Lock()
|
||||||
|
defer d.mutex.Unlock()
|
||||||
|
|
||||||
|
meta, err := fetcher(d.URL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to retrieve metadata", slog.Any("err", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Metadata = *meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DownloaderBase) SetPending(p bool) {
|
||||||
|
d.mutex.Lock()
|
||||||
|
defer d.mutex.Unlock()
|
||||||
|
d.Pending = p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DownloaderBase) Complete() {
|
||||||
|
d.mutex.Lock()
|
||||||
|
defer d.mutex.Unlock()
|
||||||
|
d.Completed = true
|
||||||
|
}
|
||||||
26
server/internal/downloaders/downloader.go
Normal file
26
server/internal/downloaders/downloader.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package downloaders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Downloader interface {
|
||||||
|
Start() error
|
||||||
|
Stop() error
|
||||||
|
Status() *internal.ProcessSnapshot
|
||||||
|
|
||||||
|
SetOutput(output internal.DownloadOutput)
|
||||||
|
SetProgress(progress internal.DownloadProgress)
|
||||||
|
SetMetadata(fetcher func(url string) (*common.DownloadMetadata, error))
|
||||||
|
SetPending(p bool)
|
||||||
|
|
||||||
|
IsCompleted() bool
|
||||||
|
|
||||||
|
UpdateSavedFilePath(path string)
|
||||||
|
|
||||||
|
RestoreFromSnapshot(*internal.ProcessSnapshot) error
|
||||||
|
|
||||||
|
GetId() string
|
||||||
|
GetUrl() string
|
||||||
|
}
|
||||||
211
server/internal/downloaders/generic.go
Normal file
211
server/internal/downloaders/generic.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package downloaders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
const downloadTemplate = `download:
|
||||||
|
{
|
||||||
|
"eta":%(progress.eta)s,
|
||||||
|
"percentage":"%(progress._percent_str)s",
|
||||||
|
"speed":%(progress.speed)s
|
||||||
|
}`
|
||||||
|
|
||||||
|
// filename not returning the correct extension after postprocess
|
||||||
|
const postprocessTemplate = `postprocess:
|
||||||
|
{
|
||||||
|
"filepath":"%(info.filepath)s"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
type GenericDownloader struct {
|
||||||
|
Params []string
|
||||||
|
|
||||||
|
AutoRemove bool
|
||||||
|
|
||||||
|
progress internal.DownloadProgress
|
||||||
|
output internal.DownloadOutput
|
||||||
|
|
||||||
|
proc *os.Process
|
||||||
|
|
||||||
|
logConsumer LogConsumer
|
||||||
|
|
||||||
|
// embedded
|
||||||
|
DownloaderBase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGenericDownload(url string, params []string) Downloader {
|
||||||
|
g := &GenericDownloader{
|
||||||
|
logConsumer: NewJSONLogConsumer(),
|
||||||
|
}
|
||||||
|
// in base
|
||||||
|
g.Id = uuid.NewString()
|
||||||
|
g.URL = url
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GenericDownloader) Start() error {
|
||||||
|
g.SetPending(true)
|
||||||
|
|
||||||
|
g.Params = argsSanitizer(g.Params)
|
||||||
|
|
||||||
|
out := internal.DownloadOutput{
|
||||||
|
Path: config.Instance().Paths.DownloadPath,
|
||||||
|
Filename: "%(title)s.%(ext)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.output.Path != "" {
|
||||||
|
out.Path = g.output.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.output.Filename != "" {
|
||||||
|
out.Filename = g.output.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFilename(&g.output)
|
||||||
|
|
||||||
|
templateReplacer := strings.NewReplacer("\n", "", "\t", "", " ", "")
|
||||||
|
|
||||||
|
baseParams := []string{
|
||||||
|
strings.Split(g.URL, "?list")[0], //no playlist
|
||||||
|
"--newline",
|
||||||
|
"--no-colors",
|
||||||
|
"--no-playlist",
|
||||||
|
"--progress-template",
|
||||||
|
templateReplacer.Replace(downloadTemplate),
|
||||||
|
"--progress-template",
|
||||||
|
templateReplacer.Replace(postprocessTemplate),
|
||||||
|
"--no-exec",
|
||||||
|
}
|
||||||
|
|
||||||
|
// if user asked to manually override the output path...
|
||||||
|
if !(slices.Contains(g.Params, "-P") || slices.Contains(g.Params, "--paths")) {
|
||||||
|
g.Params = append(g.Params, "-o")
|
||||||
|
g.Params = append(g.Params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
params := append(baseParams, g.Params...)
|
||||||
|
|
||||||
|
slog.Info("requesting download", slog.String("url", g.URL), slog.Any("params", params))
|
||||||
|
|
||||||
|
cmd := exec.Command(config.Instance().Paths.DownloaderPath, params...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get a stdout pipe", slog.Any("err", err))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get a stderr pipe", slog.Any("err", err))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
slog.Error("failed to start yt-dlp process", slog.Any("err", err))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.proc = cmd.Process
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer func() {
|
||||||
|
stdout.Close()
|
||||||
|
g.Complete()
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
logs := make(chan []byte)
|
||||||
|
go produceLogs(stdout, logs)
|
||||||
|
go consumeLogs(ctx, logs, g.logConsumer, g)
|
||||||
|
|
||||||
|
go printYtDlpErrors(stderr, g.Id, g.URL)
|
||||||
|
|
||||||
|
g.SetPending(false)
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GenericDownloader) Stop() error {
|
||||||
|
defer func() {
|
||||||
|
g.progress.Status = internal.StatusCompleted
|
||||||
|
g.Complete()
|
||||||
|
}()
|
||||||
|
// yt-dlp uses multiple child process the parent process
|
||||||
|
// has been spawned with setPgid = true. To properly kill
|
||||||
|
// all subprocesses a SIGTERM need to be sent to the correct
|
||||||
|
// process group
|
||||||
|
if g.proc == nil {
|
||||||
|
return errors.New("*os.Process not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
pgid, err := syscall.Getpgid(g.proc.Pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GenericDownloader) Status() *internal.ProcessSnapshot {
|
||||||
|
return &internal.ProcessSnapshot{
|
||||||
|
Id: g.Id,
|
||||||
|
Info: g.Metadata,
|
||||||
|
Progress: g.progress,
|
||||||
|
Output: g.output,
|
||||||
|
Params: g.Params,
|
||||||
|
DownloaderName: "generic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GenericDownloader) UpdateSavedFilePath(p string) { g.output.SavedFilePath = p }
|
||||||
|
|
||||||
|
func (g *GenericDownloader) SetOutput(o internal.DownloadOutput) { g.output = o }
|
||||||
|
func (g *GenericDownloader) SetProgress(p internal.DownloadProgress) { g.progress = p }
|
||||||
|
|
||||||
|
func (g *GenericDownloader) SetMetadata(fetcher func(url string) (*common.DownloadMetadata, error)) {
|
||||||
|
g.FetchMetadata(fetcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GenericDownloader) SetPending(p bool) {
|
||||||
|
g.Pending = p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GenericDownloader) GetId() string { return g.Id }
|
||||||
|
func (g *GenericDownloader) GetUrl() string { return g.URL }
|
||||||
|
|
||||||
|
func (g *GenericDownloader) RestoreFromSnapshot(snap *internal.ProcessSnapshot) error {
|
||||||
|
if snap == nil {
|
||||||
|
return errors.New("cannot restore nil snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := *snap
|
||||||
|
|
||||||
|
g.Id = s.Id
|
||||||
|
g.URL = s.Info.URL
|
||||||
|
g.Metadata = s.Info
|
||||||
|
g.progress = s.Progress
|
||||||
|
g.output = s.Output
|
||||||
|
g.Params = s.Params
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GenericDownloader) IsCompleted() bool { return g.Completed }
|
||||||
205
server/internal/downloaders/livestream.go
Normal file
205
server/internal/downloaders/livestream.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package downloaders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal/pipes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LiveStreamDownloader struct {
|
||||||
|
progress internal.DownloadProgress
|
||||||
|
|
||||||
|
proc *os.Process
|
||||||
|
|
||||||
|
logConsumer LogConsumer
|
||||||
|
|
||||||
|
pipes []pipes.Pipe
|
||||||
|
|
||||||
|
// embedded
|
||||||
|
DownloaderBase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLiveStreamDownloader(url string, pipes []pipes.Pipe) Downloader {
|
||||||
|
l := &LiveStreamDownloader{
|
||||||
|
logConsumer: NewFFMpegLogConsumer(),
|
||||||
|
pipes: pipes,
|
||||||
|
}
|
||||||
|
// in base
|
||||||
|
l.Id = uuid.NewString()
|
||||||
|
l.URL = url
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) Start() error {
|
||||||
|
l.SetPending(true)
|
||||||
|
|
||||||
|
baseParams := []string{
|
||||||
|
l.URL,
|
||||||
|
"--newline",
|
||||||
|
"--no-colors",
|
||||||
|
"--no-playlist",
|
||||||
|
"--no-exec",
|
||||||
|
}
|
||||||
|
|
||||||
|
params := append(baseParams, "-o", "-")
|
||||||
|
|
||||||
|
cmd := exec.Command(config.Instance().Paths.DownloaderPath, params...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
// stdout = media stream
|
||||||
|
media, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get media stdout", slog.Any("err", err))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stderr = log/progress
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get stderr pipe", slog.Any("err", err))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
slog.Error("failed to start yt-dlp process", slog.Any("err", err))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.proc = cmd.Process
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer func() {
|
||||||
|
l.Complete()
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// --- costruisci pipeline ---
|
||||||
|
reader := io.Reader(media)
|
||||||
|
for _, pipe := range l.pipes {
|
||||||
|
nr, err := pipe.Connect(reader)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("pipe failed", slog.String("pipe", pipe.Name()), slog.Any("err", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader = nr
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fallback: se nessun FileWriter, scrivi su file ---
|
||||||
|
if !l.hasFileWriter() {
|
||||||
|
go func() {
|
||||||
|
filepath.Join(
|
||||||
|
config.Instance().Paths.DownloadPath,
|
||||||
|
fmt.Sprintf("%s (live) %s.mp4", l.Id, time.Now().Format(time.ANSIC)),
|
||||||
|
)
|
||||||
|
|
||||||
|
defaultPath := filepath.Join(config.Instance().Paths.DownloadPath)
|
||||||
|
f, err := os.Create(defaultPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create fallback file", slog.Any("err", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(f, reader)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("copy error", slog.Any("err", err))
|
||||||
|
}
|
||||||
|
slog.Info("download saved", slog.String("path", defaultPath))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- logs consumer ---
|
||||||
|
logs := make(chan []byte)
|
||||||
|
go produceLogs(stderr, logs)
|
||||||
|
go consumeLogs(ctx, logs, l.logConsumer, l)
|
||||||
|
|
||||||
|
l.progress.Status = internal.StatusLiveStream
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) Stop() error {
|
||||||
|
defer func() {
|
||||||
|
l.progress.Status = internal.StatusCompleted
|
||||||
|
l.Complete()
|
||||||
|
}()
|
||||||
|
// yt-dlp uses multiple child process the parent process
|
||||||
|
// has been spawned with setPgid = true. To properly kill
|
||||||
|
// all subprocesses a SIGTERM need to be sent to the correct
|
||||||
|
// process group
|
||||||
|
if l.proc == nil {
|
||||||
|
return errors.New("*os.Process not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
pgid, err := syscall.Getpgid(l.proc.Pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) Status() *internal.ProcessSnapshot {
|
||||||
|
return &internal.ProcessSnapshot{
|
||||||
|
Id: l.Id,
|
||||||
|
Info: l.Metadata,
|
||||||
|
Progress: l.progress,
|
||||||
|
DownloaderName: "livestream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) UpdateSavedFilePath(p string) {}
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) SetOutput(o internal.DownloadOutput) {}
|
||||||
|
func (l *LiveStreamDownloader) SetProgress(p internal.DownloadProgress) { l.progress = p }
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) SetMetadata(fetcher func(url string) (*common.DownloadMetadata, error)) {
|
||||||
|
l.FetchMetadata(fetcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) SetPending(p bool) {
|
||||||
|
l.Pending = p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) GetId() string { return l.Id }
|
||||||
|
func (l *LiveStreamDownloader) GetUrl() string { return l.URL }
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) RestoreFromSnapshot(snap *internal.ProcessSnapshot) error {
|
||||||
|
if snap == nil {
|
||||||
|
return errors.New("cannot restore nil snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := *snap
|
||||||
|
|
||||||
|
l.Id = s.Id
|
||||||
|
l.URL = s.Info.URL
|
||||||
|
l.Metadata = s.Info
|
||||||
|
l.progress = s.Progress
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) IsCompleted() bool { return l.Completed }
|
||||||
|
|
||||||
|
func (l *LiveStreamDownloader) hasFileWriter() bool {
|
||||||
|
return slices.ContainsFunc(l.pipes, func(p pipes.Pipe) bool {
|
||||||
|
return p.Name() == "file-writer"
|
||||||
|
})
|
||||||
|
}
|
||||||
68
server/internal/downloaders/logging.go
Normal file
68
server/internal/downloaders/logging.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package downloaders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogConsumer interface {
|
||||||
|
GetName() string
|
||||||
|
ParseLogEntry(entry []byte, downloader Downloader)
|
||||||
|
}
|
||||||
|
|
||||||
|
type JSONLogConsumer struct{}
|
||||||
|
|
||||||
|
func NewJSONLogConsumer() LogConsumer {
|
||||||
|
return &JSONLogConsumer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JSONLogConsumer) GetName() string { return "json-log-consumer" }
|
||||||
|
|
||||||
|
func (j *JSONLogConsumer) ParseLogEntry(entry []byte, d Downloader) {
|
||||||
|
var progress internal.ProgressTemplate
|
||||||
|
var postprocess internal.PostprocessTemplate
|
||||||
|
|
||||||
|
if err := json.Unmarshal(entry, &progress); err == nil {
|
||||||
|
d.SetProgress(internal.DownloadProgress{
|
||||||
|
Status: internal.StatusDownloading,
|
||||||
|
Percentage: progress.Percentage,
|
||||||
|
Speed: progress.Speed,
|
||||||
|
ETA: progress.Eta,
|
||||||
|
})
|
||||||
|
|
||||||
|
slog.Info("progress",
|
||||||
|
slog.String("id", j.GetShortId(d.GetId())),
|
||||||
|
slog.String("url", d.GetUrl()),
|
||||||
|
slog.String("percentage", progress.Percentage),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(entry, &postprocess); err == nil {
|
||||||
|
d.UpdateSavedFilePath(postprocess.FilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JSONLogConsumer) GetShortId(id string) string {
|
||||||
|
return strings.Split(id, "-")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: split in different files
|
||||||
|
|
||||||
|
type FFMpegLogConsumer struct{}
|
||||||
|
|
||||||
|
func NewFFMpegLogConsumer() LogConsumer {
|
||||||
|
return &JSONLogConsumer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FFMpegLogConsumer) GetName() string { return "ffmpeg-log-consumer" }
|
||||||
|
|
||||||
|
func (f *FFMpegLogConsumer) ParseLogEntry(entry []byte, d Downloader) {
|
||||||
|
slog.Info("ffmpeg output",
|
||||||
|
slog.String("id", d.GetId()),
|
||||||
|
slog.String("url", d.GetUrl()),
|
||||||
|
slog.String("output", string(entry)),
|
||||||
|
)
|
||||||
|
}
|
||||||
1
server/internal/downloaders/playlist.go
Normal file
1
server/internal/downloaders/playlist.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package downloaders
|
||||||
76
server/internal/downloaders/utils.go
Normal file
76
server/internal/downloaders/utils.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package downloaders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func argsSanitizer(params []string) []string {
|
||||||
|
params = slices.DeleteFunc(params, func(e string) bool {
|
||||||
|
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
params = slices.DeleteFunc(params, func(e string) bool {
|
||||||
|
return e == ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFilename(o *internal.DownloadOutput) {
|
||||||
|
if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") {
|
||||||
|
o.Filename += ".%(ext)s"
|
||||||
|
}
|
||||||
|
|
||||||
|
o.Filename = strings.Replace(
|
||||||
|
o.Filename,
|
||||||
|
".%(ext)s.%(ext)s",
|
||||||
|
".%(ext)s",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func produceLogs(r io.Reader, logs chan<- []byte) {
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
logs <- scanner.Bytes()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeLogs(ctx context.Context, logs <-chan []byte, c LogConsumer, d Downloader) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("detaching logs",
|
||||||
|
slog.String("url", d.GetUrl()),
|
||||||
|
slog.String("id", c.GetName()),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
case entry := <-logs:
|
||||||
|
c.ParseLogEntry(entry, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printYtDlpErrors(stdout io.Reader, shortId, url string) {
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
slog.Error("yt-dlp process error",
|
||||||
|
slog.String("id", shortId),
|
||||||
|
slog.String("url", url),
|
||||||
|
slog.String("err", scanner.Text()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user