Compare commits

...

61 Commits

Author SHA1 Message Date
Marco Piovanello
f4a0f688af Feat twitch livestreams (#334)
* backend code

* fixed twitch authentication
2025-08-25 12:54:16 +02:00
Marco Piovanello
14a03d6a77 Prevent RCEs with crafted inputs 2025-07-23 10:21:34 +02:00
Marco Piovanello
8a73079fad Update Dockerfile 2025-04-13 20:13:59 +02:00
f578f44cfd refactor: prevent multiple slashes 2025-03-30 10:29:13 +02:00
cbe16c5c6c refactoring: readded abort controller to httpClient.ts 2025-03-30 10:21:19 +02:00
3cebaf7f61 refactor: extra slashes prevention 2025-03-30 10:17:30 +02:00
Marco Piovanello
2d2cb1dc3a Update README.md 2025-03-30 09:54:27 +02:00
Marco Piovanello
43bcc40907 293 tiny gui improvement (#296)
* clicking on the speed dial will open download dialog

* refactor: prevent multiple slashes
2025-03-29 21:27:28 +01:00
Marco Piovanello
2af27e51be Chore dockerfile refactor (#287)
* removed yt-dlp alpine package

* use python3-alpine base image
2025-03-22 16:17:25 +01:00
Marco Piovanello
8c18242aaf removed yt-dlp alpine package (#286) 2025-03-22 15:27:48 +01:00
Marco Piovanello
66bebb2529 Update README.md 2025-03-17 11:23:29 +01:00
Marco Piovanello
e223e030ac restrict user with a whitelist (#282) 2025-03-17 11:13:20 +01:00
e4362468f7 fixed livestreams not being monitored 2025-03-15 11:08:08 +01:00
6880f60d14 Code refactoring, added clear button 2025-03-13 11:22:17 +01:00
Marco Piovanello
5d4aa7e2a3 Update README.md 2025-03-09 17:07:56 +01:00
Piotr Hajdas
2845196bc7 Add one-click deploy options for AWS, DigitalOcean, and Render in README (#268) 2025-02-20 09:47:11 +01:00
LelieL91
983915f8aa Fixed static file location (#263)
* Update EN, IT langs

Fixed EN lang mistype error
Added missing IT keys + added more translations

* Fixed files location

- livestreams.dat now uses same location as session.data (if specified on config.yml)
- .db.lock now uses same location as database file (if specified on config.yml)

* Update migrate.go

revert edit

---------

Co-authored-by: Marco Piovanello <35533749+marcopiovanello@users.noreply.github.com>
2025-02-07 22:00:11 +01:00
ce2fb13ef2 code refactoring 2025-02-07 10:13:35 +01:00
99069fe5f7 fixed proxy subdir malformed string 2025-02-07 09:45:26 +01:00
761f26b387 subscriptions: prevent downloading already existing file 2025-02-07 09:37:47 +01:00
eec72bb6e2 handle cancellation of scheduled cron jobs 2025-02-06 19:28:03 +01:00
ceb92d066c code refactoring 2025-02-06 19:27:38 +01:00
Marco Piovanello
cf74948840 initial support for playlist modifiers (#262)
supported modifiers are --playlist-start, --playlist-end, --playlist-reverse, --max-downloads
2025-02-06 11:30:28 +01:00
LelieL91
1c62084c7b Update EN, IT langs (#261)
Fixed EN lang mistype error
Added missing IT keys + added more translations

Co-authored-by: Marco Piovanello <35533749+marcopiovanello@users.noreply.github.com>
2025-02-06 09:30:21 +01:00
3c21253562 added latest keys to each language file (not translated) 2025-02-05 11:08:15 +01:00
b243c1c958 hotfix for #259 2025-02-05 10:49:15 +01:00
Marco Piovanello
7be5bc7b1f Update README.md 2025-02-05 09:16:05 +01:00
Marco Piovanello
65960fb560 removed derived atom from async cookies atom (#259) 2025-02-04 19:04:19 +01:00
Marco Piovanello
1141903512 updated version info (#258) 2025-02-04 14:16:02 +01:00
ff93bd552f support for cron based subscriptions management 2025-02-04 13:58:58 +01:00
Kohányi Róbert
016d8557e6 feat: allow auto/pre-selected extension (#255)
* feat: allow auto/pre-selected extension

* fix: revert typo and changes made for local dev
2025-02-03 20:40:57 +01:00
5e9f92a06f ui version indicator + dependencies update 2025-02-03 10:50:38 +01:00
cc6a562e9e code refactoring 2025-02-03 10:25:20 +01:00
Marco Piovanello
67b01f9e0b fixed postprocessor args (#256)
* fixed postprocessor args

* updated node builder version

* temporary fix for https://github.com/nodejs/corepack/issues/612
2025-02-03 10:24:48 +01:00
Robi Hahn
2f2eca2bff i18n: hungarian translation (#250) 2025-01-30 18:24:57 +01:00
Patrick Thoelken
5073113568 refac: i18n (#244) 2025-01-16 16:10:00 +01:00
Marco Piovanello
430bfabfb4 i18n system refactor (#243) 2025-01-15 20:47:58 +01:00
160a2721f9 Code refactoring, renabled app title customization.
With React 19 header title API title doesn't need react-helmet anymore :)
2025-01-13 10:49:55 +01:00
Augusto Vasconcelos
6f0187bccc Update settings.ts (enable portuguese-br) (#238)
Enable portuguese-br and sort alphabetically
2025-01-08 08:31:43 +01:00
Augusto Vasconcelos
801c89df5d Update i18n.yaml (Brazilian Translation) (#237)
Added Brazilian Translation
2025-01-07 15:52:08 +01:00
Michael M. Chang
49fdaeb42a update username (#234) 2025-01-01 09:38:59 +01:00
fc07c08c58 fixed default template not selected 2024-12-23 09:35:22 +01:00
f9e829dce6 added status API endpoint 2024-12-19 13:08:25 +01:00
17fb608f45 code refactoring 2024-12-19 12:18:36 +01:00
9d3861ab39 Added better archive functionalty (backend side atm)
Code refactoring
2024-12-18 11:59:17 +01:00
d9cb018132 ready for v3.2.3 2024-12-12 10:20:57 +01:00
Marco Piovanello
ac077ea1e1 upgraded react to v19 (#232) 2024-12-12 09:49:20 +01:00
f29d719df0 fixed never awaited cookies template promise 2024-12-12 09:33:09 +01:00
Marco Piovanello
6adfa71fde custom path based frontend (#231) 2024-12-05 10:00:15 +01:00
0946d374e3 more examples 2024-11-23 09:53:44 +01:00
Marco Piovanello
f68c29f838 Update README.md 2024-11-19 11:43:42 +01:00
Marco Piovanello
c46e39e736 code refactoring (#227) 2024-11-19 11:36:36 +01:00
Marco Piovanello
2885d6b5d8 Update README.md 2024-11-17 08:58:08 +01:00
Marco Piovanello
ab7932ae92 Editable templates (#225)
* editable templates

* removed unused import
2024-11-15 14:24:44 +01:00
6c9118f67e added dockerignore 2024-11-12 11:39:21 +01:00
34c78c7e2d ready for 3.2.2 2024-11-12 11:32:50 +01:00
Marco Piovanello
01c6edef74 jotai migration (#221) 2024-11-12 11:31:25 +01:00
4a87ea559a prevent downloading playlist with format selection 2024-11-10 15:32:17 +01:00
Andrási István
846fb294d0 Fix module name to match major version v3. Simplify makefile. (#213)
Co-authored-by: Marco Piovanello <35533749+marcopeocchi@users.noreply.github.com>
2024-11-10 13:59:40 +01:00
Dusk
baa25afa27 fix: manual installation (#220) 2024-11-10 08:53:30 +01:00
Néfix Estrada
b0dac0adda fix(nix): fix package build (#208) 2024-10-13 13:38:17 +02:00
154 changed files with 6868 additions and 3209 deletions

View File

@@ -0,0 +1 @@
docker run -d -p 3033:3033 -v /downloads:/downloads marcobaobao/yt-dlp-webui

View 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"
}

31
.dockerignore Normal file
View File

@@ -0,0 +1,31 @@
.pre-commit-config.yaml
.direnv/
result/
result
dist
.pnpm-store/
.pnpm-debug.log
node_modules
.env
*.mp4
*.ytdl
*.part
*.db
downloads
.DS_Store
build/
yt-dlp-webui
session.dat
config.yml
cookies.txt
__debug*
ui/
.idea
.idea/
frontend/.pnp.cjs
frontend/.pnp.loader.mjs
frontend/.yarn/install-state.gz
.db.lock
livestreams.dat
.vite/deps
archive.txt

5
.gitignore vendored
View File

@@ -3,6 +3,7 @@
result/
result
dist
.pnpm-store/
.pnpm-debug.log
node_modules
.env
@@ -20,8 +21,12 @@ cookies.txt
__debug*
ui/
.idea
.idea/
frontend/.pnp.cjs
frontend/.pnp.loader.mjs
frontend/.yarn/install-state.gz
.db.lock
livestreams.dat
.vite/deps
archive.txt
twitch-monitor.dat

View File

@@ -1,8 +1,8 @@
# Node (pnpm) ------------------------------------------------------------------
FROM node:20-slim AS ui
FROM node:22-slim AS ui
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.0.0 --activate && corepack enable
COPY . /usr/src/yt-dlp-webui
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
# -----------------------------------------------------------------------------
# dependencies ----------------------------------------------------------------
FROM alpine:edge
# Runtime ---------------------------------------------------------------------
FROM python:3.13.2-alpine3.21
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
@@ -39,4 +40,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret
EXPOSE 3033
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]

View File

@@ -4,17 +4,15 @@ default:
go run main.go
fe:
cd frontend && pnpm build
cd frontend && pnpm install && pnpm build
dev:
cd frontend && pnpm dev
cd frontend && pnpm install && pnpm dev
all:
$(MAKE) fe && cd ..
all: fe
CGO_ENABLED=0 go build -o yt-dlp-webui main.go
multiarch:
$(MAKE) fe
multiarch: fe
mkdir -p build
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go

View File

@@ -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
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.
**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
docker pull marcobaobao/yt-dlp-webui
```
```sh
# 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
@@ -21,13 +25,18 @@ docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
*Keeps the project alive!* 😃
## Community stuff
Feel free to join :)
[Discord](https://discord.gg/GZAX5FfGzE)
## Some screeshots
![image](https://github.com/user-attachments/assets/fc43a3fb-ecf9-449d-b5cb-5d5635020c00)
![image](https://github.com/user-attachments/assets/3210f6ac-0dd8-403c-b839-3c24ff7d7d00)
![image](https://github.com/user-attachments/assets/16450a40-cda6-4c8b-9d20-8ec36282f6ed)
## 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
@@ -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!
## [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
docker pull 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
```
## [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
# download the latest release from the releases page
@@ -151,6 +170,8 @@ Usage yt-dlp-webui:
session file path (default ".")
-user string
Username required for auth
-web string
frontend web resources path
```
### Config file
@@ -180,7 +201,7 @@ password: my_random_secret
queue_size: 4 # min. 2
# [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)
#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")
#local_database_path
# [optional] Path where a custom frontend will be loaded (instead of the embedded one)
#frontend_path: ./web/solid-frontend
```
### 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.
## 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
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/).

View 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;
}
}

View 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

View File

@@ -1,39 +1,39 @@
{
"name": "yt-dlp-webui",
"version": "3.2.1",
"version": "3.2.5",
"description": "Frontend compontent of yt-dlp-webui",
"scripts": {
"dev": "vite",
"dev": "vite --host 0.0.0.0",
"build": "vite build"
},
"author": "marcopeocchi",
"license": "MPL-2.0",
"type": "module",
"author": "marcopiovanello",
"license": "GPL-3.0-only",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.0.13",
"@fontsource/roboto-mono": "^5.0.18",
"@mui/icons-material": "^5.15.16",
"@mui/material": "^5.15.16",
"@mui/icons-material": "^6.2.0",
"@mui/material": "^6.2.0",
"fp-ts": "^2.16.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.23.1",
"react-virtuoso": "^4.7.11",
"recoil": "^0.7.7",
"jotai": "^2.10.3",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/node": "^20.14.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.2.18",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@types/react-helmet": "^6.1.11",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.7.0",
"million": "^3.1.11",
"typescript": "^5.4.5",
"vite": "^5.2.11"
"@vitejs/plugin-react-swc": "^3.7.2",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}

1697
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,11 +1,12 @@
import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft'
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import Dashboard from '@mui/icons-material/Dashboard'
import LiveTvIcon from '@mui/icons-material/LiveTv'
import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings'
import TerminalIcon from '@mui/icons-material/Terminal'
import UpdateIcon from '@mui/icons-material/Update'
import { Box, createTheme } from '@mui/material'
import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider'
@@ -17,9 +18,9 @@ import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors'
import { useAtomValue } from 'jotai'
import { useMemo, useState } from 'react'
import { Link, Outlet } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { settingsState } from './atoms/settings'
import AppBar from './components/AppBar'
import Drawer from './components/Drawer'
@@ -29,22 +30,26 @@ import SocketSubscriber from './components/SocketSubscriber'
import ThemeToggler from './components/ThemeToggler'
import { useI18n } from './hooks/useI18n'
import Toaster from './providers/ToasterProvider'
import { getAccentValue } from './utils'
export default function Layout() {
const [open, setOpen] = useState(false)
const settings = useRecoilValue(settingsState)
const settings = useAtomValue(settingsState)
const mode = settings.theme
const theme = useMemo(() =>
createTheme({
palette: {
mode: settings.theme,
primary: {
main: getAccentValue(settings.accent, settings.theme)
},
background: {
default: settings.theme === 'light' ? grey[50] : '#121212'
},
},
}), [settings.theme]
}), [settings.theme, settings.accent]
)
const toggleDrawer = () => setOpen(state => !state)
@@ -53,6 +58,7 @@ export default function Layout() {
return (
<ThemeProvider theme={theme}>
<title>{settings.appTitle}</title>
<SocketSubscriber />
<Box sx={{ display: 'flex' }}>
<CssBaseline />
@@ -109,7 +115,7 @@ export default function Layout() {
<ListItemText primary={i18n.t('homeButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/archive'} style={
{/* <Link to={'/archive'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
@@ -121,6 +127,32 @@ export default function Layout() {
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</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={'/monitor'} style={
{

View File

@@ -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:
english:
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
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!
catalan: ca.yaml
german: de.yaml
english: en_US.yaml
spanish: es.yaml
french: fr.yaml
italian: it_IT.yaml
japanese: ja.yaml
korean: ko.yaml
polish: pl.yaml
portuguese-br: pt_BR.yaml
russian: ru.yaml
swedish: sv.yaml
ukrainian: uk.yaml
chinese: zh_CN.yaml
hungarian: hu.yaml

View 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

View 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

View File

@@ -0,0 +1,83 @@
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'

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,26 @@
import { pipe } from 'fp-ts/lib/function'
import { matchW } from 'fp-ts/lib/TaskEither'
import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient'
import { prefersDarkMode } from '../utils'
import { atomWithStorage } from 'jotai/utils'
import { atom } from 'jotai'
export const languages = [
'english',
'chinese',
'russian',
'french',
'italian',
'spanish',
'korean',
'japanese',
'catalan',
'ukrainian',
'swedish',
'chinese',
'english',
'french',
'german',
'italian',
'japanese',
'korean',
'polish',
'german'
'portuguese-br',
'russian',
'spanish',
'swedish',
'ukrainian',
'hungarian'
] as const
export type Language = (typeof languages)[number]
@@ -25,14 +28,19 @@ export type Language = (typeof languages)[number]
export type Theme = 'light' | 'dark' | 'system'
export type ThemeNarrowed = 'light' | 'dark'
export const accents = ['default', 'red'] as const
export type Accent = (typeof accents)[number]
export interface SettingsState {
serverAddr: string
serverPort: number
language: Language
theme: ThemeNarrowed
accent: Accent
cliArgs: string
formatSelection: boolean
fileRenaming: boolean
autoFileExtension: boolean
pathOverriding: boolean
enableCustomArgs: boolean
listView: boolean
@@ -40,194 +48,145 @@ export interface SettingsState {
appTitle: string
}
export const languageState = atom<Language>({
key: 'languageState',
default: localStorage.getItem('language') as Language || 'english',
effects: [
({ onSet }) =>
onSet(l => localStorage.setItem('language', l.toString()))
]
})
export const languageState = atomWithStorage<Language>(
'language',
localStorage.getItem('language') as Language || 'english'
)
export const themeState = atom<Theme>({
key: 'themeStateState',
default: localStorage.getItem('theme') as Theme || 'system',
effects: [
({ onSet }) =>
onSet(l => localStorage.setItem('theme', l.toString()))
]
})
export const themeState = atomWithStorage<Theme>(
'theme',
localStorage.getItem('theme') as Theme || 'system'
)
export const serverAddressState = atom<string>({
key: 'serverAddressState',
default: localStorage.getItem('server-addr') || window.location.hostname,
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('server-addr', a.toString()))
]
})
export const serverAddressState = atomWithStorage<string>(
'server-addr',
localStorage.getItem('server-addr') || window.location.hostname
)
export const serverPortState = atom<number>({
key: 'serverPortState',
default: Number(localStorage.getItem('server-port')) || Number(window.location.port),
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('server-port', a.toString()))
]
})
export const serverPortState = atomWithStorage<number>(
'server-port',
Number(localStorage.getItem('server-port')) || Number(window.location.port)
)
export const latestCliArgumentsState = atom<string>({
key: 'latestCliArgumentsState',
default: localStorage.getItem('cli-args') || '--no-mtime',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('cli-args', a.toString()))
]
})
export const latestCliArgumentsState = atomWithStorage<string>(
'cli-args',
localStorage.getItem('cli-args') || '--no-mtime'
)
export const formatSelectionState = atom({
key: 'formatSelectionState',
default: localStorage.getItem('format-selection') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('format-selection', a.toString()))
]
})
export const formatSelectionState = atomWithStorage(
'format-selection',
localStorage.getItem('format-selection') === 'true'
)
export const fileRenamingState = atom({
key: 'fileRenamingState',
default: localStorage.getItem('file-renaming') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('file-renaming', a.toString()))
]
})
export const fileRenamingState = atomWithStorage(
'file-renaming',
localStorage.getItem('file-renaming') === 'true'
)
export const pathOverridingState = atom({
key: 'pathOverridingState',
default: localStorage.getItem('path-overriding') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('path-overriding', a.toString()))
]
})
export const autoFileExtensionState = atomWithStorage(
'auto-file-extension',
localStorage.getItem('auto-file-extension') === 'true'
)
export const enableCustomArgsState = atom({
key: 'enableCustomArgsState',
default: localStorage.getItem('enable-custom-args') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('enable-custom-args', a.toString()))
]
})
export const pathOverridingState = atomWithStorage(
'path-overriding',
localStorage.getItem('path-overriding') === 'true'
)
export const listViewState = atom({
key: 'listViewState',
default: localStorage.getItem('listview') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('listview', a.toString()))
]
})
export const enableCustomArgsState = atomWithStorage(
'enable-custom-args',
localStorage.getItem('enable-custom-args') === 'true'
)
export const servedFromReverseProxyState = atom({
key: 'servedFromReverseProxyState',
default: localStorage.getItem('reverseProxy') === "true" || window.location.port == "",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxy', a.toString()))
]
})
export const listViewState = atomWithStorage(
'listview',
localStorage.getItem('listview') === 'true'
)
export const servedFromReverseProxySubDirState = atom<string>({
key: 'servedFromReverseProxySubDirState',
default: localStorage.getItem('reverseProxySubDir') ?? '',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxySubDir', a))
]
})
export const servedFromReverseProxyState = atomWithStorage(
'reverseProxy',
localStorage.getItem('reverseProxy') === 'true' || window.location.port == ''
)
export const appTitleState = atom({
key: 'appTitleState',
default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('appTitle', a.toString()))
]
})
export const servedFromReverseProxySubDirState = atomWithStorage<string>(
'reverseProxySubDir',
localStorage.getItem('reverseProxySubDir') ?? ''
)
export const serverAddressAndPortState = selector({
key: 'serverAddressAndPortState',
get: ({ get }) => {
if (get(servedFromReverseProxySubDirState)) {
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
}
if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}`
}
return `${get(serverAddressState)}:${get(serverPortState)}`
export const appTitleState = atomWithStorage(
'appTitle',
localStorage.getItem('appTitle') ?? 'yt-dlp Web UI'
)
export const serverAddressAndPortState = atom((get) => {
if (get(servedFromReverseProxySubDirState)) {
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
.replaceAll('"', '') // XXX: atomWithStorage uses JSON.stringify to serialize
.replaceAll('//', '/') // which puts extra double quotes.
}
})
export const serverURL = selector({
key: 'serverURL',
get: ({ get }) =>
`${window.location.protocol}//${get(serverAddressAndPortState)}`
})
export const rpcWebSocketEndpoint = selector({
key: 'rpcWebSocketEndpoint',
get: ({ get }) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}`
.replaceAll('"', '')
}
const sap = `${get(serverAddressState)}:${get(serverPortState)}`
.replaceAll('"', '')
return sap.endsWith('/') ? sap.slice(0, -1) : sap
})
export const rpcHTTPEndpoint = selector({
key: 'rpcHTTPEndpoint',
get: ({ get }) => {
const proto = window.location.protocol
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
export const serverURL = atom((get) =>
`${window.location.protocol}//${get(serverAddressAndPortState)}`
)
export const rpcWebSocketEndpoint = atom((get) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const sap = get(serverAddressAndPortState)
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/ws`
})
export const rpcHTTPEndpoint = atom((get) => {
const proto = window.location.protocol
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(
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
matchW(
() => '',
(r) => r.cookies
)
)())
const themeSelector = atom<ThemeNarrowed>((get) => {
const theme = get(themeState)
if ((theme === 'system' && prefersDarkMode()) || theme === 'dark') {
return 'dark'
}
return 'light'
})
export const serverSideCookiesState = selector<string>({
key: 'serverSideCookiesState',
get: async ({ get }) => await pipe(
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
matchW(
() => '',
(r) => r.cookies
)
)()
})
export const accentState = atomWithStorage<Accent>(
'accent-color',
localStorage.getItem('accent-color') as Accent ?? 'default',
)
const themeSelector = selector<ThemeNarrowed>({
key: 'themeSelector',
get: ({ get }) => {
const theme = get(themeState)
if ((theme === 'system' && prefersDarkMode()) || theme === 'dark') {
return 'dark'
}
return 'light'
}
})
export const settingsState = selector<SettingsState>({
key: 'settingsState',
get: ({ get }) => ({
serverAddr: get(serverAddressState),
serverPort: get(serverPortState),
language: get(languageState),
theme: get(themeSelector),
cliArgs: get(latestCliArgumentsState),
formatSelection: get(formatSelectionState),
fileRenaming: get(fileRenamingState),
pathOverriding: get(pathOverridingState),
enableCustomArgs: get(enableCustomArgsState),
listView: get(listViewState),
servedFromReverseProxy: get(servedFromReverseProxyState),
appTitle: get(appTitleState)
})
})
export const settingsState = atom<SettingsState>((get) => ({
serverAddr: get(serverAddressState),
serverPort: get(serverPortState),
language: get(languageState),
theme: get(themeSelector),
accent: get(accentState),
cliArgs: get(latestCliArgumentsState),
formatSelection: get(formatSelectionState),
fileRenaming: get(fileRenamingState),
autoFileExtension: get(autoFileExtensionState),
pathOverriding: get(pathOverridingState),
enableCustomArgs: get(enableCustomArgsState),
listView: get(listViewState),
servedFromReverseProxy: get(servedFromReverseProxyState),
appTitle: get(appTitleState)
}))

View File

@@ -1,45 +1,34 @@
import { pipe } from 'fp-ts/lib/function'
import { of } from 'fp-ts/lib/Task'
import { getOrElse } from 'fp-ts/lib/TaskEither'
import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient'
import { RPCVersion } from '../types'
import { rpcClientState } from './rpc'
import { serverURL } from './settings'
import { atom } from 'jotai'
export const connectedState = atom({
key: 'connectedState',
default: false
export const connectedState = atom(false)
export const freeSpaceBytesState = atom(async (get) => {
const res = await get(rpcClientState)
.freeSpace()
.catch(() => ({ result: 0 }))
return res.result
})
export const freeSpaceBytesState = selector({
key: 'freeSpaceBytesState',
get: async ({ get }) => {
const res = await get(rpcClientState).freeSpace()
.catch(() => ({ result: 0 }))
return res.result
}
export const availableDownloadPathsState = atom(async (get) => {
const res = await get(rpcClientState).directoryTree()
.catch(() => ({ result: [] }))
return res.result
})
export const availableDownloadPathsState = selector({
key: 'availableDownloadPathsState',
get: async ({ get }) => {
const res = await get(rpcClientState).directoryTree()
.catch(() => ({ result: [] }))
return res.result
}
})
export const ytdlpRpcVersionState = selector<RPCVersion>({
key: 'ytdlpRpcVersionState',
get: async ({ get }) => await pipe(
ffetch<RPCVersion>(`${get(serverURL)}/api/v1/version`),
getOrElse(() => pipe(
{
rpcVersion: 'unknown version',
ytdlpVersion: 'unknown version',
},
of
)),
)()
})
export const ytdlpRpcVersionState = atom<Promise<RPCVersion>>(async (get) => await pipe(
ffetch<RPCVersion>(`${get(serverURL)}/api/v1/version`),
getOrElse(() => pipe(
{
rpcVersion: 'unknown version',
ytdlpVersion: 'unknown version',
},
of
)),
)())

View File

@@ -1,5 +1,5 @@
import { AlertColor } from '@mui/material'
import { atom } from 'recoil'
import { atom } from 'jotai'
export type Toast = {
open: boolean,
@@ -9,7 +9,4 @@ export type Toast = {
severity?: AlertColor
}
export const toastListState = atom<Toast[]>({
key: 'toastListState',
default: [],
})
export const toastListState = atom<Toast[]>([])

View File

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

View 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

View File

@@ -5,12 +5,15 @@ import * as O from 'fp-ts/Option'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
import { useMemo } from 'react'
import { useRecoilValue } from 'recoil'
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { serverSideCookiesState, serverURL } from '../atoms/settings'
import { useSubscription } from '../hooks/observable'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
import { useAtomValue } from 'jotai'
import { useI18n } from '../hooks/useI18n'
const { i18n } = useI18n()
const validateCookie = (cookie: string) => pipe(
cookie,
@@ -75,8 +78,8 @@ const noopValidator = (s: string): E.Either<string, string[]> => pipe(
const isCommentOrNewLine = (s: string) => s === '' || s.startsWith('\n') || s.startsWith('#')
const CookiesTextField: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const savedCookies = useRecoilValue(serverSideCookiesState)
const serverAddr = useAtomValue(serverURL)
const savedCookies = useAtomValue(serverSideCookiesState)
const { pushMessage } = useToast()
@@ -164,7 +167,7 @@ const CookiesTextField: React.FC = () => {
defaultValue={savedCookies}
onChange={(e) => cookies$.next(e.currentTarget.value)}
/>
<Button onClick={deleteCookies}>Delete cookies</Button>
<Button onClick={deleteCookies}>{i18n.t('deleteCookies')}</Button>
</>
)
}

View 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

View File

@@ -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 {
Button,
Card,
@@ -10,16 +6,23 @@ import {
CardContent,
CardMedia,
Chip,
IconButton,
LinearProgress,
Skeleton,
Stack,
Tooltip,
Typography
} from '@mui/material'
import { useAtomValue } from 'jotai'
import { useCallback } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { RPCResult } from '../types'
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
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 = {
download: RPCResult
@@ -27,17 +30,8 @@ type Props = {
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 serverAddr = useRecoilValue(serverURL)
const serverAddr = useAtomValue(serverURL)
const isCompleted = useCallback(
() => download.progress.percentage === '-1',
@@ -53,12 +47,12 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
const viewFile = (path: string) => {
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 encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
}
return (
@@ -110,37 +104,44 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
<Typography>
{formatSize(download.info.filesize_approx ?? 0)}
</Typography>
<Resolution resolution={download.info.resolution} />
<ResolutionBadge resolution={download.info.resolution} />
</Stack>
</CardContent>
</CardActionArea>
<CardActions>
<Button
variant="contained"
size="small"
color="primary"
onClick={onStop}
>
{isCompleted() ? "Clear" : "Stop"}
</Button>
{isCompleted() ?
<Tooltip title="Clear from the view">
<IconButton
onClick={onStop}
>
<ClearIcon />
</IconButton>
</Tooltip>
:
<Tooltip title="Stop this download">
<IconButton
onClick={onStop}
>
<StopCircleIcon />
</IconButton>
</Tooltip>
}
{isCompleted() &&
<>
<Button
variant="contained"
size="small"
color="primary"
onClick={() => downloadFile(download.output.savedFilePath)}
>
Download
</Button>
<Button
variant="contained"
size="small"
color="primary"
onClick={() => viewFile(download.output.savedFilePath)}
>
View
</Button>
<Tooltip title="Download this file">
<IconButton
onClick={() => downloadFile(download.output.savedFilePath)}
>
<SaveAltIcon />
</IconButton>
</Tooltip>
<Tooltip title="Open in a new tab">
<IconButton
onClick={() => viewFile(download.output.savedFilePath)}
>
<OpenInBrowserIcon />
</IconButton>
</Tooltip>
</>
}
</CardActions>

View File

@@ -2,7 +2,6 @@ import { FileUpload } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close'
import {
Autocomplete,
Backdrop,
Box,
Button,
Checkbox,
@@ -12,7 +11,10 @@ import {
Grid,
IconButton,
InputAdornment,
MenuItem,
Paper,
Select,
SelectChangeEvent,
TextField
} from '@mui/material'
import AppBar from '@mui/material/AppBar'
@@ -21,25 +23,32 @@ import Slide from '@mui/material/Slide'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import { TransitionProps } from '@mui/material/transitions'
import { useAtom, useAtomValue } from 'jotai'
import {
FC,
Suspense,
forwardRef,
useEffect,
useRef,
useState,
useTransition
} from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate'
import {
cookiesTemplateState,
customArgsState,
filenameTemplateState,
savedTemplatesState
} from '../atoms/downloadTemplate'
import { settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import type { DLMetadata } from '../types'
import { toFormatArgs } from '../utils'
import CustomArgsTextField from './CustomArgsTextField'
import ExtraDownloadOptions from './ExtraDownloadOptions'
import LoadingBackdrop from './LoadingBackdrop'
const Transition = forwardRef(function Transition(
props: TransitionProps & {
@@ -57,41 +66,40 @@ type Props = {
}
const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState)
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
const downloadTemplate = useRecoilValue(downloadTemplateState)
const savedTemplates = useRecoilValue(savedTemplatesState)
const settings = useAtomValue(settingsState)
const isConnected = useAtomValue(connectedState)
const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
const savedTemplates = useAtomValue(savedTemplatesState)
const customArgs = useAtomValue(customArgsState)
const cookies = useAtomValue(cookiesTemplateState)
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
const [downloadPath, setDownloadPath] = useState('')
const [filenameTemplate, setFilenameTemplate] = useRecoilState(
const [filenameTemplate, setFilenameTemplate] = useAtom(
filenameTemplateState
)
const [fileExtension, setFileExtension] = useState('.%(ext)s')
const [url, setUrl] = useState('')
const [isPlaylist, setIsPlaylist] = useState(false)
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null)
const [isPending, startTransition] = useTransition()
useEffect(() => {
setCustomArgs('')
}, [open])
/**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
*/
@@ -102,12 +110,16 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
const downloadTemplate = `${customArgs} ${cookies}`
.replace(/ +/g, ' ')
.trim()
await new Promise(r => setTimeout(r, 10))
client.download({
url: immediate || line,
args: `${toFormatArgs(codes)} ${downloadTemplate}`,
pathOverride: downloadPath ?? '',
renameTo: settings.fileRenaming ? filenameTemplate : '',
renameTo: settings.fileRenaming ? filenameTemplate + (settings.autoFileExtension ? fileExtension : '') : '',
playlist: isPlaylist,
})
@@ -129,11 +141,28 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
setPickedVideoFormat('')
setPickedBestFormat('')
if (isPlaylist) {
pushMessage('Format selection on playlist is not supported', 'warning')
resetInput()
onClose()
return
}
setIsFormatsLoading(true)
client.formats(url)
?.then(formats => {
if (formats.result._type === 'playlist') {
pushMessage('Format selection on playlist is not supported. Downloading as playlist.', 'info')
resetInput()
onClose()
return
}
setDownloadFormats(formats.result)
resetInput()
})
.then(() => setIsFormatsLoading(false))
}
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -144,8 +173,8 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
setFilenameTemplate(e.target.value)
}
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomArgs(e.target.value)
const handleFileExtensionChange = (e: SelectChangeEvent<string>) => {
setFileExtension(e.target.value)
}
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -175,10 +204,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
onClose={onClose}
TransitionComponent={Transition}
>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isPending}
/>
<LoadingBackdrop isLoading={isPending || isFormatsLoading} />
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
@@ -246,25 +272,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
/>
</Grid>
<Grid container spacing={1} sx={{ mt: 1 }}>
{
settings.enableCustomArgs &&
{settings.enableCustomArgs &&
<Grid item xs={12}>
<TextField
fullWidth
label={i18n.t('customArgsInput')}
variant="outlined"
onChange={handleCustomArgsChange}
value={customArgs}
disabled={
!isConnected ||
(settings.formatSelection && downloadFormats != null)
}
/>
<CustomArgsTextField />
</Grid>
}
{
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
sx={{ mt: 1 }}
ref={customFilenameInputRef}
@@ -280,6 +303,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
/>
</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 &&
<Grid item xs={4}>
@@ -326,7 +365,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
disabled={url === ''}
onClick={() => settings.formatSelection
? startTransition(() => sendUrlFormatSelection())
: sendUrl()
: startTransition(async () => await sendUrl())
}
>
{

View File

@@ -1,5 +1,5 @@
import { useAtom, useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { loadingDownloadsState } from '../atoms/downloads'
import { listViewState } from '../atoms/settings'
import { loadingAtom } from '../atoms/ui'
@@ -7,10 +7,10 @@ import DownloadsGridView from './DownloadsGridView'
import DownloadsTableView from './DownloadsTableView'
const Downloads: React.FC = () => {
const tableView = useRecoilValue(listViewState)
const loadingDownloads = useRecoilValue(loadingDownloadsState)
const tableView = useAtomValue(listViewState)
const loadingDownloads = useAtomValue(loadingDownloadsState)
const [isLoading, setIsLoading] = useRecoilState(loadingAtom)
const [isLoading, setIsLoading] = useAtom(loadingAtom)
useEffect(() => {
if (loadingDownloads) {

View File

@@ -1,37 +1,46 @@
import { Grid } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { Grid2 } from '@mui/material'
import { useAtomValue } from 'jotai'
import { useTransition } from 'react'
import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types'
import DownloadCard from './DownloadCard'
import LoadingBackdrop from './LoadingBackdrop'
const DownloadsGridView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState)
const downloads = useAtomValue(activeDownloadsState)
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
? client.clear(r.id)
: client.kill(r.id)
const [isPending, startTransition] = useTransition()
const stop = async (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
? await client.clear(r.id)
: await client.kill(r.id)
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
{
downloads.map(download => (
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
<DownloadCard
download={download}
onStop={() => stop(download)}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/>
</Grid>
))
}
</Grid>
<>
<LoadingBackdrop isLoading={isPending} />
<Grid2 container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
{
downloads.map(download => (
<Grid2 size={{ xs: 4, sm: 8, md: 6, xl: 4 }} key={download.id}>
<DownloadCard
download={download}
onStop={() => startTransition(async () => {
await stop(download)
})}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/>
</Grid2>
))
}
</Grid2>
</>
)
}

View File

@@ -20,12 +20,12 @@ import {
} from "@mui/material"
import { forwardRef } from 'react'
import { TableComponents, TableVirtuoso } from 'react-virtuoso'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types'
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
import { useAtomValue } from 'jotai'
const columns = [
{
@@ -119,18 +119,18 @@ function fixedHeaderContent() {
}
const DownloadsTableView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState)
const serverAddr = useRecoilValue(serverURL)
const downloads = useAtomValue(activeDownloadsState)
const serverAddr = useAtomValue(serverURL)
const { client } = useRPC()
const viewFile = (path: string) => {
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 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

View 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>
)
}

View File

@@ -1,13 +1,22 @@
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
import { useRecoilState, useRecoilValue } from 'recoil'
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect } from 'react'
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
import { useI18n } from '../hooks/useI18n'
const ExtraDownloadOptions: React.FC = () => {
const { i18n } = useI18n()
const customTemplates = useRecoilValue(savedTemplatesState)
const [, setCustomArgs] = useRecoilState(customArgsState)
const customTemplates = useAtomValue(savedTemplatesState)
const setCustomArgs = useSetAtom(customArgsState)
useEffect(() => {
setCustomArgs(
customTemplates
.find(f => f.name.toLocaleLowerCase() === 'default')
?.content ?? ''
)
}, [])
return (
<>
@@ -17,7 +26,7 @@ const ExtraDownloadOptions: React.FC = () => {
autoHighlight
defaultValue={
customTemplates
.filter(({ id, name }) => id === "0" || name === "default")
.filter(({ id, name }) => id === "0" || name.toLowerCase() === "default")
.map(({ name, content }) => ({ label: name, content }))
.at(0)
}

View File

@@ -2,7 +2,6 @@ import DownloadIcon from '@mui/icons-material/Download'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import { AppBar, CircularProgress, Divider, Toolbar } from '@mui/material'
import { Suspense } from 'react'
import { useRecoilValue } from 'recoil'
import { settingsState } from '../atoms/settings'
import { connectedState } from '../atoms/status'
import { totalDownloadSpeedState } from '../atoms/ui'
@@ -10,11 +9,12 @@ import { useI18n } from '../hooks/useI18n'
import { formatSpeedMiB } from '../utils'
import FreeSpaceIndicator from './FreeSpaceIndicator'
import VersionIndicator from './VersionIndicator'
import { useAtomValue } from 'jotai'
const Footer: React.FC = () => {
const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState)
const totalDownloadSpeed = useRecoilValue(totalDownloadSpeedState)
const settings = useAtomValue(settingsState)
const isConnected = useAtomValue(connectedState)
const totalDownloadSpeed = useAtomValue(totalDownloadSpeedState)
const mode = settings.theme
const { i18n } = useI18n()

View File

@@ -1,10 +1,10 @@
import StorageIcon from '@mui/icons-material/Storage'
import { useRecoilValue } from 'recoil'
import { freeSpaceBytesState } from '../atoms/status'
import { formatSize } from '../utils'
import { useAtomValue } from 'jotai'
const FreeSpaceIndicator = () => {
const freeSpace = useRecoilValue(freeSpaceBytesState)
const freeSpace = useAtomValue(freeSpaceBytesState)
return (
<div style={{

View File

@@ -1,5 +1,5 @@
import { useSetAtom } from 'jotai'
import { Suspense, useState } from 'react'
import { useRecoilState } from 'recoil'
import { loadingAtom } from '../atoms/ui'
import { useToast } from '../hooks/toast'
import DownloadDialog from './DownloadDialog'
@@ -7,7 +7,7 @@ import HomeSpeedDial from './HomeSpeedDial'
import TemplatesEditor from './TemplatesEditor'
const HomeActions: React.FC = () => {
const [, setIsLoading] = useRecoilState(loadingAtom)
const setIsLoading = useSetAtom(loadingAtom)
const [openDownload, setOpenDownload] = useState(false)
const [openEditor, setOpenEditor] = useState(false)

View File

@@ -1,5 +1,6 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
import ClearAllIcon from '@mui/icons-material/ClearAll'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FolderZipIcon from '@mui/icons-material/FolderZip'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
@@ -9,7 +10,7 @@ import {
SpeedDialAction,
SpeedDialIcon
} from '@mui/material'
import { useRecoilState, useRecoilValue } from 'recoil'
import { useAtom, useAtomValue } from 'jotai'
import { listViewState, serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
@@ -20,8 +21,8 @@ type Props = {
}
const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const serverAddr = useRecoilValue(serverURL)
const [listView, setListView] = useRecoilState(listViewState)
const serverAddr = useAtomValue(serverURL)
const [listView, setListView] = useAtom(listViewState)
const { i18n } = useI18n()
const { client } = useRPC()
@@ -31,10 +32,11 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />}
onClick={onDownloadOpen}
>
<SpeedDialAction
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
tooltipTitle={listView ? 'Card view' : 'Table view'}
tooltipTitle={listView ? 'Card view' : i18n.t('tableView')}
onClick={() => setListView(state => !state)}
/>
<SpeedDialAction
@@ -42,6 +44,11 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
tooltipTitle={i18n.t('bulkDownload')}
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
/>
<SpeedDialAction
icon={<ClearAllIcon />}
tooltipTitle={i18n.t('clearCompletedButton')}
onClick={() => client.clearCompleted()}
/>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
import { useAtomValue } from 'jotai'
const token = localStorage.getItem('token')
@@ -11,7 +11,7 @@ const LogTerminal: React.FC = () => {
const boxRef = useRef<HTMLDivElement>(null)
const serverAddr = useRecoilValue(serverURL)
const serverAddr = useAtomValue(serverURL)
const { i18n } = useI18n()

View 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

View File

@@ -1,7 +1,6 @@
import * as O from 'fp-ts/Option'
import { useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRecoilState, useRecoilValue } from 'recoil'
import { take, timer } from 'rxjs'
import { downloadsState } from '../atoms/downloads'
import { rpcPollingTimeState } from '../atoms/rpc'
@@ -12,15 +11,16 @@ import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { datetimeCompareFunc, isRPCResponse } from '../utils'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
const SocketSubscriber: React.FC<Props> = () => {
const [connected, setIsConnected] = useRecoilState(connectedState)
const [, setDownloads] = useRecoilState(downloadsState)
const [connected, setIsConnected] = useAtom(connectedState)
const setDownloads = useSetAtom(downloadsState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
const rpcPollingTime = useRecoilValue(rpcPollingTimeState)
const serverAddressAndPort = useAtomValue(serverAddressAndPortState)
const rpcPollingTime = useAtomValue(rpcPollingTimeState)
const { i18n } = useI18n()
const { client } = useRPC()

View File

@@ -1,8 +1,8 @@
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import { Container, SvgIcon, Typography, styled } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useI18n } from '../hooks/useI18n'
import { useAtomValue } from 'jotai'
const FlexContainer = styled(Container)({
display: 'flex',
@@ -23,7 +23,7 @@ const Title = styled(Typography)({
export default function Splash() {
const { i18n } = useI18n()
const activeDownloads = useRecoilValue(activeDownloadsState)
const activeDownloads = useAtomValue(activeDownloadsState)
if (activeDownloads.length !== 0) {
return null

View 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

View File

@@ -1,6 +1,5 @@
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
import DeleteIcon from '@mui/icons-material/Delete'
import {
Alert,
AppBar,
@@ -19,13 +18,14 @@ import {
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, useEffect, useState, useTransition } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient'
import { CustomTemplate } from '../types'
import TemplateTextField from './TemplateTextField'
const Transition = forwardRef(function Transition(
props: TransitionProps & {
@@ -45,7 +45,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
const [templateName, setTemplateName] = useState('')
const [templateContent, setTemplateContent] = useState('')
const serverAddr = useRecoilValue(serverURL)
const serverAddr = useAtomValue(serverURL)
const [isPending, startTransition] = useTransition()
const [templates, setTemplates] = useState<CustomTemplate[]>([])
@@ -55,11 +55,11 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
useEffect(() => {
if (open) {
getTemplates()
fetchTemplates()
}
}, [open])
const getTemplates = async () => {
const fetchTemplates = async () => {
const task = ffetch<CustomTemplate[]>(`${serverAddr}/api/v1/template/all`)
const either = await task()
@@ -89,7 +89,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
(l) => pushMessage(l, 'warning'),
() => {
pushMessage('Added template')
getTemplates()
fetchTemplates()
setTemplateName('')
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 task = ffetch<unknown>(`${serverAddr}/api/v1/template/${id}`, {
method: 'DELETE',
@@ -110,7 +130,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
(l) => pushMessage(l, 'warning'),
() => {
pushMessage('Deleted template')
getTemplates()
fetchTemplates()
}
)
)
@@ -179,7 +199,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
InputProps={{
endAdornment: <Button
variant='contained'
onClick={() => startTransition(() => { addTemplate() })}
onClick={() => startTransition(async () => await addTemplate())}
>
<AddIcon />
</Button>
@@ -188,38 +208,12 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
</Grid>
</Grid>
{templates.map(template => (
<Grid
container
spacing={2}
justifyContent="center"
alignItems="center"
<TemplateTextField
key={template.id}
sx={{ mt: 1 }}
>
<Grid item xs={3}>
<TextField
fullWidth
label={i18n.t('templatesEditorNameLabel')}
value={template.name}
/>
</Grid>
<Grid item xs={9}>
<TextField
fullWidth
label={i18n.t('templatesEditorContentLabel')}
value={template.content}
InputProps={{
endAdornment: <Button
variant='contained'
onClick={() => {
startTransition(() => { deleteTemplate(template.id) })
}}>
<DeleteIcon />
</Button>
}}
/>
</Grid>
</Grid>
template={template}
onChange={updateTemplate}
onDelete={deleteTemplate}
/>
))}
</Paper>
</Grid>

View File

@@ -2,12 +2,12 @@ import Brightness4 from '@mui/icons-material/Brightness4'
import Brightness5 from '@mui/icons-material/Brightness5'
import BrightnessAuto from '@mui/icons-material/BrightnessAuto'
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useRecoilState } from 'recoil'
import { Theme, themeState } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
import { useAtom } from 'jotai'
const ThemeToggler: React.FC = () => {
const [theme, setTheme] = useRecoilState(themeState)
const [theme, setTheme] = useAtom(themeState)
const actions: Record<Theme, React.ReactNode> = {
system: <BrightnessAuto />,

View 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

View File

@@ -1,12 +1,13 @@
import { Chip } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { ytdlpRpcVersionState } from '../atoms/status'
import { useAtomValue } from 'jotai'
const VersionIndicator: React.FC = () => {
const version = useRecoilValue(ytdlpRpcVersionState)
const version = useAtomValue(ytdlpRpcVersionState)
return (
<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={`yt-dlp v${version.ytdlpVersion}`} variant="outlined" size="small" />
</div>

View File

@@ -31,7 +31,7 @@ export default function NoLivestreams() {
</SvgIcon>
</Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
No livestreams monitored
{i18n.t('livestreamNoMonitoring')}
</Title>
</FlexContainer>
)

View 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>
)
}

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -1,9 +1,9 @@
import { AlertColor } from '@mui/material'
import { useRecoilState } from 'recoil'
import { toastListState } from '../atoms/toast'
import { useSetAtom } from 'jotai'
export const useToast = () => {
const [, setToasts] = useRecoilState(toastListState)
const setToasts = useSetAtom(toastListState)
return {
pushMessage: (message: string, severity?: AlertColor) => {

View 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

View File

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

View File

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

View File

@@ -1,12 +1,9 @@
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(
() => fetcher<T>(url, opt),
(e) => `error while fetching: ${e}`
)
const fetcher = async <T>(url: string, opt?: RequestInit) => {
async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise<string> {
const jwt = localStorage.getItem('token')
if (opt && !opt.headers) {
@@ -20,11 +17,27 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
headers: {
...opt?.headers,
'X-Authentication': jwt ?? ''
}
},
signal: controller?.signal
})
if (!res.ok) {
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
View 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
}
}

View File

@@ -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'
}
}

View File

@@ -41,11 +41,13 @@ export class RPCClient {
})
}
private argsSanitizer(args: string) {
private argsSanitizer(args: string): string[] {
const splitOnlyWhitespaces = /[^\s"']+|"([^"]*)"|'([^']*)'/gm
return args
.split(' ')
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
.filter(Boolean)
.match(splitOnlyWhitespaces)
?.map(a => a.trim())
.filter(Boolean) ?? []
}
private async sendHTTP<T>(req: RPCRequest) {
@@ -128,21 +130,21 @@ export class RPCClient {
}
public kill(id: string) {
this.sendHTTP({
return this.sendHTTP({
method: 'Service.Kill',
params: [id],
})
}
public clear(id: string) {
this.sendHTTP({
return this.sendHTTP({
method: 'Service.Clear',
params: [id],
})
}
public killAll() {
this.sendHTTP({
return this.sendHTTP({
method: 'Service.KillAll',
params: [],
})
@@ -191,4 +193,18 @@ export class RPCClient {
params: []
})
}
public updateExecutable() {
return this.sendHTTP({
method: 'Service.UpdateExecutable',
params: []
})
}
public clearCompleted() {
return this.sendHTTP({
method: 'Service.ClearCompleted',
params: []
})
}
}

View File

@@ -1,10 +1,10 @@
import { Alert, Snackbar } from "@mui/material"
import { useRecoilState } from 'recoil'
import { Toast, toastListState } from '../atoms/toast'
import { useEffect } from 'react'
import { useAtom } from 'jotai'
const Toaster: React.FC = () => {
const [toasts, setToasts] = useRecoilState(toastListState)
const [toasts, setToasts] = useAtom(toastListState)
const deletePredicate = (t: Toast) => (Date.now() - t.createdAt) < 2000

View File

@@ -9,6 +9,8 @@ const Login = lazy(() => import('./views/Login'))
const Archive = lazy(() => import('./views/Archive'))
const Settings = lazy(() => import('./views/Settings'))
const LiveStream = lazy(() => import('./views/Livestream'))
const Filebrowser = lazy(() => import('./views/Filebrowser'))
const Subscriptions = lazy(() => import('./views/Subscriptions'))
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
@@ -59,6 +61,32 @@ export const router = createHashRouter([
</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',
element: (

View 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

View File

@@ -13,6 +13,7 @@ export type RPCMethods =
| "Service.ProgressLivestream"
| "Service.KillLivestream"
| "Service.KillAllLivestream"
| "Service.ClearCompleted"
export type RPCRequest = {
method: RPCMethods
@@ -69,9 +70,11 @@ export type RPCParams = {
export type DLMetadata = {
formats: Array<DLFormat>
_type: string
best: DLFormat
thumbnail: string
title: string
entries: Array<DLMetadata>
}
export type DLFormat = {
@@ -120,4 +123,20 @@ export type LiveStreamProgress = Record<string, {
export type RPCVersion = {
rpcVersion: 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
}

View File

@@ -1,4 +1,6 @@
import { blue, red } from '@mui/material/colors'
import { pipe } from 'fp-ts/lib/function'
import { Accent, ThemeNarrowed } from './atoms/settings'
import type { RPCResponse } from "./types"
import { ProcessStatus } from './types'
@@ -79,4 +81,15 @@ export const base64URLEncode = (s: string) => pipe(
s => String.fromCodePoint(...new TextEncoder().encode(s)),
btoa,
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]
}
}

View File

@@ -1,363 +1,112 @@
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 { Container, FormControl, Grid2, InputLabel, MenuItem, Pagination, Select } from '@mui/material'
import { pipe } from 'fp-ts/lib/function'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
import { matchW } from 'fp-ts/lib/TaskEither'
import { useAtomValue } from 'jotai'
import { useEffect, useState, useTransition } from 'react'
import { serverURL } from '../atoms/settings'
import { useObservable } from '../hooks/observable'
import ArchiveCard from '../components/ArchiveCard'
import EmptyArchive from '../components/EmptyArchive'
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 { ArchiveEntry, PaginatedResponse } from '../types'
import LoadingBackdrop from '../components/LoadingBackdrop'
export default function Downloaded() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
const [showMenu, setShowMenu] = useState(false)
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
const Archive: React.FC = () => {
const [isLoading, setLoading] = useState(true)
const [archiveEntries, setArchiveEntries] = useState<ArchiveEntry[]>()
const serverAddr = useRecoilValue(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 [currentCursor, setCurrentCursor] = useState(0)
const [cursor, setCursor] = useState({ first: 0, next: 0 })
const [pageSize, setPageSize] = useState(25)
const [isPending, startTransition] = useTransition()
const fetcher = () => pipe(
ffetch<DirectoryEntry[]>(
`${serverAddr}/archive/downloaded`,
{
method: 'POST',
body: JSON.stringify({
subdir: '',
})
}
),
const serverAddr = useAtomValue(serverURL)
const { pushMessage } = useToast()
const fetchArchived = (startCursor = 0) => pipe(
ffetch<PaginatedResponse<ArchiveEntry[]>>(`${serverAddr}/archive?id=${startCursor}&limit=${pageSize}`),
matchW(
(e) => {
pushMessage(e, 'error')
navigate('/login')
},
(d) => files$.next(d ?? []),
(l) => pushMessage(l, 'error'),
(r) => {
setArchiveEntries(r.data)
setCursor({ ...cursor, first: r.first, next: r.next })
}
)
)()
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}/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,
})
const softDelete = (id: string) => pipe(
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/soft/${id}`, {
method: 'DELETE'
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => fetcher()
(_) => startTransition(async () => await fetchArchived())
)
)()
const deleteSelected = () => {
Promise.all(selectable
.filter(entry => entry.selected)
.map(deleteFile)
).then(fetcher)
}
const hardDelete = (id: string) => pipe(
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/hard/${id}`, {
method: 'DELETE'
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => startTransition(async () => await fetchArchived())
)
)()
const setPage = (page: number) => setCurrentCursor(pageSize * (page - 1))
useEffect(() => {
fetcher()
}, [serverAddr])
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)
})
fetchArchived(currentCursor).then(() => setLoading(false))
}, [currentCursor])
return (
<Container
maxWidth="xl"
sx={{ mt: 4, mb: 4, height: '100%' }}
onClick={() => setShowMenu(false)}
>
<IconMenu
posX={menuPos.x}
posY={menuPos.y}
hide={!showMenu}
onDownload={() => {
if (currentFile) {
downloadFile(currentFile?.path)
setCurrentFile(undefined)
}
}}
onDelete={() => {
if (currentFile) {
deleteFile(currentFile)
setCurrentFile(undefined)
}
}}
/>
<Backdrop
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
<Container maxWidth="xl" sx={{ mt: 4, mb: 8, minHeight: '80vh' }}>
<LoadingBackdrop isLoading={isPending || isLoading} />
{
archiveEntries && archiveEntries.length !== 0 ?
<Grid2
container
spacing={{ xs: 2, md: 2 }}
columns={{ xs: 4, sm: 8, md: 12, xl: 12 }}
pt={2}
sx={{ minHeight: '77.5vh' }}
>
Ok
</Button>
</DialogActions>
</Dialog>
{
archiveEntries.map((entry) => (
<Grid2 size={{ xs: 4, sm: 8, md: 4, xl: 3 }} key={entry.id}>
<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>
)
}
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>
)
}
export default Archive

View 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>
)
}

View File

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

View File

@@ -6,8 +6,10 @@ import {
Paper,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
} from '@mui/material'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { interval } from 'rxjs'
import { rpcPollingTimeState } from '../atoms/rpc'
import LivestreamDialog from '../components/livestream/LivestreamDialog'
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
import NoLivestreams from '../components/livestream/NoLivestreams'
@@ -24,7 +26,9 @@ const LiveStreamMonitorView: React.FC = () => {
const [progress, setProgress] = useState<LiveStreamProgress>()
const [openDialog, setOpenDialog] = useState(false)
useSubscription(interval(1000), () => {
const rpcPollingRate = useAtomValue(rpcPollingTimeState)
useSubscription(interval(rpcPollingRate), () => {
client
.progressLivestream()
.then(r => setProgress(r.result))

View File

@@ -16,10 +16,10 @@ import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
import { useAtomValue } from 'jotai'
const LoginContainer = styled(Container)({
display: 'flex',
@@ -43,7 +43,7 @@ export default function Login() {
const [formHasError, setFormHasError] = useState(false)
const url = useRecoilValue(serverURL)
const url = useAtomValue(serverURL)
const navigate = useNavigate()
@@ -93,7 +93,7 @@ export default function Login() {
</Title>
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
To configure authentication check the&nbsp;
<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>
<TextField
label="Username"

View File

@@ -1,5 +1,4 @@
import {
Button,
Checkbox,
Container,
FormControl,
@@ -18,8 +17,8 @@ import {
Typography,
capitalize
} from '@mui/material'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRecoilState } from 'recoil'
import { useAtom } from 'jotai'
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import {
Subject,
debounceTime,
@@ -29,11 +28,15 @@ import {
} from 'rxjs'
import { rpcPollingTimeState } from '../atoms/rpc'
import {
Accent,
Language,
Theme,
accentState,
accents,
appTitleState,
enableCustomArgsState,
fileRenamingState,
autoFileExtensionState,
formatSelectionState,
languageState,
languages,
@@ -45,34 +48,36 @@ import {
themeState
} from '../atoms/settings'
import CookiesTextField from '../components/CookiesTextField'
import UpdateBinaryButton from '../components/UpdateBinaryButton'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import Translator from '../lib/i18n'
import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() {
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
const [baseURL, setBaseURL] = useRecoilState(servedFromReverseProxySubDirState)
const [reverseProxy, setReverseProxy] = useAtom(servedFromReverseProxyState)
const [baseURL, setBaseURL] = useAtom(servedFromReverseProxySubDirState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState)
const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
const [autoFileExtension, setAutoFileExtension] = useAtom(autoFileExtensionState)
const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
const [serverPort, setServerPort] = useRecoilState(serverPortState)
const [serverAddr, setServerAddr] = useAtom(serverAddressState)
const [serverPort, setServerPort] = useAtom(serverPortState)
const [pollingTime, setPollingTime] = useRecoilState(rpcPollingTimeState)
const [language, setLanguage] = useRecoilState(languageState)
const [appTitle, setApptitle] = useRecoilState(appTitleState)
const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState)
const [language, setLanguage] = useAtom(languageState)
const [appTitle, setApptitle] = useAtom(appTitleState)
const [accent, setAccent] = useAtom(accentState)
const [theme, setTheme] = useRecoilState(themeState)
const [theme, setTheme] = useAtom(themeState)
const [invalidIP, setInvalidIP] = useState(false)
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
@@ -80,6 +85,9 @@ export default function Settings() {
const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), [])
const [, updateState] = useState({})
const forceUpdate = useCallback(() => updateState({}), [])
useEffect(() => {
const sub = baseURL$
.pipe(debounceTime(500))
@@ -131,6 +139,11 @@ export default function Settings() {
*/
const handleLanguageChange = (event: SelectChangeEvent<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)
}
/**
* Updates yt-dlp binary via RPC
*/
const updateBinary = () => {
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
}
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Paper
@@ -185,7 +191,6 @@ export default function Settings() {
</Grid>
<Grid item xs={12} md={12}>
<TextField
disabled={reverseProxy}
fullWidth
label={i18n.t('appTitle')}
defaultValue={appTitle}
@@ -257,7 +262,7 @@ export default function Settings() {
Appearance
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
<Select
@@ -281,15 +286,31 @@ export default function Settings() {
label={i18n.t('themeSelect')}
onChange={handleThemeChange}
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
<MenuItem value="light">{i18n.t('lightThemeButton')}</MenuItem>
<MenuItem value="dark">{i18n.t('darkThemeButton')}</MenuItem>
<MenuItem value="system">System</MenuItem>
</Select>
</FormControl>
</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>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
General download settings
{i18n.t('generalDownloadSettings')}
</Typography>
<FormControlLabel
@@ -324,12 +345,30 @@ export default function Settings() {
<Switch
defaultChecked={fileRenaming}
onChange={() => {
if (fileRenaming) {
setAutoFileExtension(false)
}
setFileRenaming(state => !state)
}}
/>
}
label={i18n.t('filenameOverrideOption')}
/>
{
<FormControlLabel
control={
<Switch
disabled={!fileRenaming}
checked={fileRenaming ? autoFileExtension : false}
defaultChecked={autoFileExtension}
onChange={() => {
setAutoFileExtension(state => !state)
}}
/>
}
label={i18n.t('autoFileExtensionOption')}
/>
}
<FormControlLabel
control={
<Switch
@@ -352,14 +391,8 @@ export default function Settings() {
</Suspense>
</Grid>
<Grid>
<Stack direction="row">
<Button
sx={{ mr: 1, mt: 3 }}
variant="contained"
onClick={() => updateBinary()}
>
{i18n.t('updateBinButton')}
</Button>
<Stack direction="row" sx={{ pt: 2 }}>
<UpdateBinaryButton />
</Stack>
</Grid>
</Paper>

View 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

View File

@@ -1,12 +1,10 @@
import react from '@vitejs/plugin-react-swc'
import million from 'million/compiler'
import ViteYaml from '@modyfi/vite-plugin-yaml'
import { defineConfig } from 'vite'
export default defineConfig(() => {
return {
plugins: [
million.vite({ auto: true }),
react(),
ViteYaml(),
],

33
go.mod
View File

@@ -1,37 +1,32 @@
module github.com/marcopeocchi/yt-dlp-web-ui
module github.com/marcopiovanello/yt-dlp-web-ui/v3
go 1.23
go 1.24
require (
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-chi/chi/v5 v5.1.0
github.com/coreos/go-oidc/v3 v3.12.0
github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.8.0
golang.org/x/sys v0.25.0
github.com/robfig/cron/v3 v3.0.0
golang.org/x/oauth2 v0.25.0
golang.org/x/sync v0.10.0
golang.org/x/sys v0.29.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.33.1
modernc.org/sqlite v1.34.5
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
modernc.org/libc v1.61.11 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
)

90
go.sum
View File

@@ -1,13 +1,13 @@
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
@@ -22,13 +22,6 @@ 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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/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=
@@ -37,51 +30,50 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.15 h1:wFDan71KnYqeHz4eF63vmGE6Q6Pc0PUGDpP0PRMYjDc=
modernc.org/ccgo/v4 v4.23.15/go.mod h1:nJX30dks/IWuBOnVa7VRii9Me4/9TZ1SC9GNtmARTy0=
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/gc/v2 v2.6.2 h1:YBXi5Kqp6aCK3fIxwKQ3/fErvawVKwjOLItxj1brGds=
modernc.org/gc/v2 v2.6.2/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.11 h1:6sZG8uB6EMMG7iTLPTndi8jyTdgAQNIeLGjCFICACZw=
modernc.org/libc v1.61.11/go.mod h1:HHX+srFdn839oaJRd0W8hBM3eg+mieyZCAjWwB08/nM=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

14
main.go
View File

@@ -8,10 +8,10 @@ import (
"os"
"runtime"
"github.com/marcopeocchi/yt-dlp-web-ui/server"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/cli"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
)
var (
@@ -23,6 +23,7 @@ var (
downloaderPath string
sessionFilePath string
localDatabasePath string
frontendPath string
requireAuth bool
username string
@@ -52,6 +53,7 @@ func init() {
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.StringVar(&frontendPath, "web", "", "frontend web resources path")
flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file")
flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location")
@@ -69,6 +71,10 @@ func main() {
log.Fatalln(err)
}
if frontendPath != "" {
frontend = os.DirFS(frontendPath)
}
c := config.Instance()
{

View File

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

View File

@@ -1,9 +1,9 @@
{ yt-dlp-web-ui-frontend, buildGoModule, lib, makeWrapper, yt-dlp, ... }:
{ yt-dlp-web-ui-frontend, buildGo123Module, lib, makeWrapper, yt-dlp, ... }:
let
fs = lib.fileset;
common = import ./common.nix { inherit lib; };
in
buildGoModule {
buildGo123Module {
pname = "yt-dlp-web-ui";
inherit (common) version;
src = fs.toSource rec {
@@ -26,7 +26,7 @@ buildGoModule {
# repo commons
../.github
../README.md
../LICENSE.md
../LICENSE
../.gitignore
../.vscode
]);
@@ -44,7 +44,7 @@ buildGoModule {
--prefix PATH : ${lib.makeBinPath [ yt-dlp ]}
'';
vendorHash = "sha256-guM/U9DROJMx2ctPKBQis1YRhaf6fKvvwEWgswQKMG0=";
vendorHash = "sha256-c7IdCmYJEn5qJn3K8wt0qz3t0Nq9rbgWp1eONlCJOwM=";
meta = common.meta // {
mainProgram = "yt-dlp-web-ui";

View File

@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Swagger yt-dlp-webui - OpenAPI 3.1",
"description": "yt-dlp-webui api based on the OpenAPI 3.1 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). \n\nSome useful links:\n- [yt-dlp-webui repository](https://github.com/marcopeocchi/yt-dlp-web-ui)",
"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/",
"contact": {
"email": "apiteam@swagger.io"
@@ -28,7 +28,7 @@
"description": "Everything about your Pets",
"externalDocs": {
"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
View 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()
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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().RequireAuth {
r.Use(middlewares.Authenticated)
}
if config.Instance().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())
}
}

View 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)
}

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