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

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Video showcase
|
## Video showcase
|
||||||
[app.webm](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964)
|
[app.webm](https://github.com/marcopiovanello/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964)
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
@@ -52,7 +57,7 @@ This feature is disabled by default as this intended to be used to retrieve the
|
|||||||
|
|
||||||
To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag!
|
To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag!
|
||||||
|
|
||||||
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) run
|
## [Docker](https://github.com/marcopiovanello/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) run
|
||||||
```sh
|
```sh
|
||||||
docker pull marcobaobao/yt-dlp-webui
|
docker pull marcobaobao/yt-dlp-webui
|
||||||
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
|
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
|
||||||
@@ -110,7 +115,17 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
|
### ⚡ One-Click Deploy
|
||||||
|
|
||||||
|
| Cloud Provider | Deploy Button |
|
||||||
|
|----------------|---------------|
|
||||||
|
| AWS | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=aws&language=cfn"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/aws.svg" height="38"></a> |
|
||||||
|
| DigitalOcean | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=do&language=dop"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/do.svg" height="38"></a> |
|
||||||
|
| Render | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=rnd&language=rnd"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/rnd.svg" height="38"></a> |
|
||||||
|
|
||||||
|
<sub>Generated by <a href="https://deploystack.io/c/marcopiovanello-yt-dlp-web-ui" target="_blank">DeployStack.io</a></sub>
|
||||||
|
|
||||||
|
## [Prebuilt binaries](https://github.com/marcopiovanello/yt-dlp-web-ui/releases) installation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# download the latest release from the releases page
|
# download the latest release from the releases page
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "yt-dlp-webui",
|
"name": "yt-dlp-webui",
|
||||||
"version": "3.2.2",
|
"version": "3.2.6",
|
||||||
"description": "Frontend compontent of yt-dlp-webui",
|
"description": "Frontend compontent of yt-dlp-webui",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"author": "marcopeocchi",
|
"type": "module",
|
||||||
|
"author": "marcopiovanello",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.11.5",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@fontsource/roboto": "^5.0.13",
|
"@fontsource/roboto": "^5.0.13",
|
||||||
"@fontsource/roboto-mono": "^5.0.18",
|
"@fontsource/roboto-mono": "^5.0.18",
|
||||||
"@mui/icons-material": "^5.15.16",
|
"@mui/icons-material": "^6.2.0",
|
||||||
"@mui/material": "^5.15.16",
|
"@mui/material": "^6.2.0",
|
||||||
"fp-ts": "^2.16.5",
|
"fp-ts": "^2.16.5",
|
||||||
"react": "^18.3.1",
|
"jotai": "^2.10.3",
|
||||||
"react-dom": "^18.3.1",
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"react-virtuoso": "^4.7.11",
|
"react-virtuoso": "^4.7.11",
|
||||||
"jotai": "^2.10.2",
|
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"@types/node": "^20.14.2",
|
"@types/node": "^20.14.2",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^19.0.1",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@types/react-helmet": "^6.1.11",
|
"@types/react-helmet": "^6.1.11",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
"million": "^3.1.11",
|
"typescript": "^5.7.2",
|
||||||
"typescript": "^5.4.5",
|
"vite": "^6.0.3"
|
||||||
"vite": "^5.2.11"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1668
frontend/pnpm-lock.yaml
generated
1668
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
import { ThemeProvider } from '@emotion/react'
|
import { ThemeProvider } from '@emotion/react'
|
||||||
import ArchiveIcon from '@mui/icons-material/Archive'
|
|
||||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||||
|
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||||
import Dashboard from '@mui/icons-material/Dashboard'
|
import Dashboard from '@mui/icons-material/Dashboard'
|
||||||
import LiveTvIcon from '@mui/icons-material/LiveTv'
|
import LiveTvIcon from '@mui/icons-material/LiveTv'
|
||||||
import Menu from '@mui/icons-material/Menu'
|
import Menu from '@mui/icons-material/Menu'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||||
|
import UpdateIcon from '@mui/icons-material/Update'
|
||||||
import { Box, createTheme } from '@mui/material'
|
import { Box, createTheme } from '@mui/material'
|
||||||
import CssBaseline from '@mui/material/CssBaseline'
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
@@ -17,6 +18,7 @@ import ListItemText from '@mui/material/ListItemText'
|
|||||||
import Toolbar from '@mui/material/Toolbar'
|
import Toolbar from '@mui/material/Toolbar'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { grey } from '@mui/material/colors'
|
import { grey } from '@mui/material/colors'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link, Outlet } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
import { settingsState } from './atoms/settings'
|
import { settingsState } from './atoms/settings'
|
||||||
@@ -26,9 +28,10 @@ import Footer from './components/Footer'
|
|||||||
import Logout from './components/Logout'
|
import Logout from './components/Logout'
|
||||||
import SocketSubscriber from './components/SocketSubscriber'
|
import SocketSubscriber from './components/SocketSubscriber'
|
||||||
import ThemeToggler from './components/ThemeToggler'
|
import ThemeToggler from './components/ThemeToggler'
|
||||||
|
import TwitchIcon from './components/TwitchIcon'
|
||||||
import { useI18n } from './hooks/useI18n'
|
import { useI18n } from './hooks/useI18n'
|
||||||
import Toaster from './providers/ToasterProvider'
|
import Toaster from './providers/ToasterProvider'
|
||||||
import { useAtomValue } from 'jotai'
|
import { getAccentValue } from './utils'
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -40,11 +43,14 @@ export default function Layout() {
|
|||||||
createTheme({
|
createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: settings.theme,
|
mode: settings.theme,
|
||||||
|
primary: {
|
||||||
|
main: getAccentValue(settings.accent, settings.theme)
|
||||||
|
},
|
||||||
background: {
|
background: {
|
||||||
default: settings.theme === 'light' ? grey[50] : '#121212'
|
default: settings.theme === 'light' ? grey[50] : '#121212'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}), [settings.theme]
|
}), [settings.theme, settings.accent]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleDrawer = () => setOpen(state => !state)
|
const toggleDrawer = () => setOpen(state => !state)
|
||||||
@@ -53,6 +59,7 @@ export default function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
|
<title>{settings.appTitle}</title>
|
||||||
<SocketSubscriber />
|
<SocketSubscriber />
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
@@ -109,7 +116,7 @@ export default function Layout() {
|
|||||||
<ListItemText primary={i18n.t('homeButtonLabel')} />
|
<ListItemText primary={i18n.t('homeButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={'/archive'} style={
|
{/* <Link to={'/archive'} style={
|
||||||
{
|
{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
@@ -121,6 +128,45 @@ export default function Layout() {
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
</Link> */}
|
||||||
|
<Link to={'/filebrowser'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<CloudDownloadIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
|
<Link to={'/subscriptions'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<UpdateIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={i18n.t('subscriptionsButtonLabel')} />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
|
<Link to={'/twitch'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<TwitchIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={"Twitch"} />
|
||||||
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={'/monitor'} style={
|
<Link to={'/monitor'} style={
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,791 +1,22 @@
|
|||||||
---
|
---
|
||||||
|
# Check the i18n src/assets/i18n folder.
|
||||||
|
#
|
||||||
|
# This file maps the language name to its translations file
|
||||||
|
# english -> /src/assets/i18n/en_US.yaml
|
||||||
|
|
||||||
languages:
|
languages:
|
||||||
english:
|
catalan: ca.yaml
|
||||||
urlInput: Video URL (one per line)
|
german: de.yaml
|
||||||
statusTitle: Status
|
english: en_US.yaml
|
||||||
statusReady: Ready
|
spanish: es.yaml
|
||||||
selectFormatButton: Select format
|
french: fr.yaml
|
||||||
startButton: Start
|
italian: it_IT.yaml
|
||||||
abortAllButton: Abort All
|
japanese: ja.yaml
|
||||||
updateBinButton: Update yt-dlp binary
|
korean: ko.yaml
|
||||||
darkThemeButton: Dark theme
|
polish: pl.yaml
|
||||||
lightThemeButton: Light theme
|
portuguese-br: pt_BR.yaml
|
||||||
settingsAnchor: Settings
|
russian: ru.yaml
|
||||||
serverAddressTitle: Server address
|
swedish: sv.yaml
|
||||||
serverPortTitle: Port
|
ukrainian: uk.yaml
|
||||||
extractAudioCheckbox: Extract audio
|
chinese: zh_CN.yaml
|
||||||
noMTimeCheckbox: Don't set file modification time
|
hungarian: hu.yaml
|
||||||
bgReminder: Once you close this page the download will continue in the background.
|
|
||||||
toastConnected: 'Connected to '
|
|
||||||
toastUpdated: Updated yt-dlp binary!
|
|
||||||
formatSelectionEnabler: Enable video/audio formats selection
|
|
||||||
themeSelect: 'Theme'
|
|
||||||
languageSelect: 'Language'
|
|
||||||
overridesAnchor: Overrides
|
|
||||||
pathOverrideOption: Enable output path overriding
|
|
||||||
filenameOverrideOption: Enable output file name overriding
|
|
||||||
customFilename: Custom filename (leave blank to use default)
|
|
||||||
customPath: Custom path
|
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
|
|
||||||
customArgsInput: Custom yt-dlp arguments
|
|
||||||
rpcConnErr: Error while conencting to RPC server
|
|
||||||
splashText: No active downloads
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: Copied URL to clipboard
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
|
|
||||||
restartAppMessage: Needs a page reload to take effect
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
|
||||||
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
rpcPollingTimeTitle: RPC polling time
|
|
||||||
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
|
||||||
templatesReloadInfo: To register a new template it might need a page reload.
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
german:
|
|
||||||
urlInput: Video URL
|
|
||||||
statusTitle: Status
|
|
||||||
statusReady: Bereit
|
|
||||||
selectFormatButton: Format auswählen
|
|
||||||
startButton: Start
|
|
||||||
abortAllButton: Alle Abbrechen
|
|
||||||
updateBinButton: yt-dlp Binärdatei aktualisieren
|
|
||||||
darkThemeButton: Dunkel Modus
|
|
||||||
lightThemeButton: Hell Modus
|
|
||||||
settingsAnchor: Einstellungen
|
|
||||||
serverAddressTitle: Server Adresse
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Audio extrahieren
|
|
||||||
noMTimeCheckbox: Datei-Änderungszeitpunkt nicht festlegen
|
|
||||||
bgReminder: Sobald Sie diese Seite schließen, wird der Download im Hintergrund fortgesetzt.
|
|
||||||
toastConnected: 'Verbunden mit '
|
|
||||||
toastUpdated: yt-dlp Binärdatei aktualisiert!
|
|
||||||
formatSelectionEnabler: Video/Audio Format auswählbar
|
|
||||||
themeSelect: 'Modus'
|
|
||||||
languageSelect: 'Sprache'
|
|
||||||
overridesAnchor: Überschreibungen
|
|
||||||
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
|
|
||||||
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
|
|
||||||
customFilename: Custom filename (leave blank to use default)
|
|
||||||
customPath: Benutzerdefinierter Pfad
|
|
||||||
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
|
|
||||||
customArgsInput: Benutzerdefinierte yt-dlp Argumente
|
|
||||||
rpcConnErr: Fehler beim Verbinden mit RPC Server
|
|
||||||
splashText: Keine aktiven Downloads
|
|
||||||
archiveTitle: Archiv
|
|
||||||
clipboardAction: URL in Zwischenablage kopiert
|
|
||||||
playlistCheckbox: Playlist herunterladen (es wird einige Zeit dauern, nach dem Absenden können Sie dieses Fenster schließen)
|
|
||||||
restartAppMessage: Erfordert ein Neuladen der Seite, um wirksam zu werden
|
|
||||||
servedFromReverseProxyCheckbox: Ist hinter einem Reverse Proxy Unterordner
|
|
||||||
newDownloadButton: Neuer Download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archiv
|
|
||||||
settingsButtonLabel: Einstellungen
|
|
||||||
rpcAuthenticationLabel: RPC Authentifizierung
|
|
||||||
themeTogglerLabel: Modus Umschalter
|
|
||||||
loadingLabel: Lädt...
|
|
||||||
appTitle: App Titel
|
|
||||||
savedTemplates: Gespeicherte Vorlage
|
|
||||||
templatesEditor: Vorlagen Bearbeiter
|
|
||||||
templatesEditorNameLabel: Vorlagen Name
|
|
||||||
templatesEditorContentLabel: Vorlagen Inhalt
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
french:
|
|
||||||
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
|
||||||
statusTitle: Statut
|
|
||||||
statusReady: Prêt
|
|
||||||
selectFormatButton: Sélectionner le format
|
|
||||||
startButton: Démarrer
|
|
||||||
abortAllButton: Tout arrêter
|
|
||||||
updateBinButton: Mettre à jour l'exécutable yt-dlp
|
|
||||||
darkThemeButton: Thème sombre
|
|
||||||
lightThemeButton: Thème clair
|
|
||||||
settingsAnchor: Paramètres
|
|
||||||
serverAddressTitle: Adresse du serveur
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Extraire l'audio
|
|
||||||
noMTimeCheckbox: Ne pas définir le temps de modification du fichier
|
|
||||||
bgReminder: Une fois que vous aurez fermé cette page, le téléchargement continuera en arrière-plan.
|
|
||||||
toastConnected: 'Connecté à '
|
|
||||||
toastUpdated: L'exécutable yt-dlp a été mis à jour !
|
|
||||||
formatSelectionEnabler: Activer la sélection des formats vidéo/audio
|
|
||||||
themeSelect: 'Thème'
|
|
||||||
languageSelect: 'Langue'
|
|
||||||
overridesAnchor: Remplacer
|
|
||||||
pathOverrideOption: Activer le remplacement du chemin de sortie
|
|
||||||
filenameOverrideOption: Activer le remplacement du nom du fichier de sortie
|
|
||||||
customFilename: Nom de fichier personnalisé (laisser vide pour utiliser le nom par défaut)
|
|
||||||
customPath: Chemin personnalisé
|
|
||||||
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
|
|
||||||
customArgsInput: Arguments yt-dlp personnalisés
|
|
||||||
rpcConnErr: Erreur lors de la connexion au serveur RPC
|
|
||||||
splashText: Aucun téléchargement actif
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: URL copiée dans le presse-papiers
|
|
||||||
playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée)
|
|
||||||
restartAppMessage: Nécessite un rechargement de la page pour prendre effet
|
|
||||||
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
|
|
||||||
notConnectedText: not connected
|
|
||||||
settingsLabel: Settings
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: Nom de l'application
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
italian:
|
|
||||||
urlInput: URL Video (uno per linea)
|
|
||||||
statusTitle: Stato
|
|
||||||
startButton: Inizia
|
|
||||||
statusReady: Pronto
|
|
||||||
abortAllButton: Termina tutto
|
|
||||||
updateBinButton: Aggiorna yt-dlp
|
|
||||||
darkThemeButton: Tema scuro
|
|
||||||
lightThemeButton: Tema chiaro
|
|
||||||
settingsAnchor: Impostazioni
|
|
||||||
serverAddressTitle: Indirizzo server
|
|
||||||
serverPortTitle: Porta
|
|
||||||
extractAudioCheckbox: Estrai l'audio
|
|
||||||
noMTimeCheckbox: Non impostare la proprietà "Data ultima modifica"
|
|
||||||
bgReminder: Chiusa questa UI il download continuerà in background.
|
|
||||||
toastConnected: 'Connesso a '
|
|
||||||
toastUpdated: yt-dlp aggiornato con successo!
|
|
||||||
formatSelectionEnabler: Abilita la selezione dei formati audio/video
|
|
||||||
themeSelect: 'Tema'
|
|
||||||
languageSelect: 'Lingua'
|
|
||||||
overridesAnchor: Sovrascritture
|
|
||||||
pathOverrideOption: Abilita sovrascrittura percorso di output
|
|
||||||
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
|
||||||
customFilename: Custom filename (leave blank to use default)
|
|
||||||
customPath: Custom path
|
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
|
||||||
customArgsInput: Custom yt-dlp arguments
|
|
||||||
rpcConnErr: Error nella connessione al server RPC
|
|
||||||
splashText: Nessun download attivo
|
|
||||||
archiveTitle: Archivio
|
|
||||||
clipboardAction: URL copiato negli appunti
|
|
||||||
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
|
|
||||||
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
|
||||||
newDownloadButton: Nuovo download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: Titolo applicazione
|
|
||||||
savedTemplates: Template salvati
|
|
||||||
templatesEditor: Editor template
|
|
||||||
templatesEditorNameLabel: Nome template
|
|
||||||
templatesEditorContentLabel: Contentunto template
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
chinese:
|
|
||||||
urlInput: 视频 URL
|
|
||||||
statusTitle: 状态
|
|
||||||
statusReady: 就绪
|
|
||||||
selectFormatButton: 选择格式
|
|
||||||
startButton: 开始
|
|
||||||
abortAllButton: 全部中止
|
|
||||||
updateBinButton: 更新 yt-dlp 可执行文件
|
|
||||||
darkThemeButton: 黑暗主题
|
|
||||||
lightThemeButton: 明亮主题
|
|
||||||
settingsAnchor: 设置
|
|
||||||
serverAddressTitle: 服务器地址
|
|
||||||
serverPortTitle: 端口
|
|
||||||
extractAudioCheckbox: 提取音频
|
|
||||||
noMTimeCheckbox: 不设置文件修改时间
|
|
||||||
bgReminder: 关闭页面后,下载会继续在后台运行。
|
|
||||||
toastConnected: '已连接到 '
|
|
||||||
toastUpdated: 已更新 yt-dlp 可执行文件!
|
|
||||||
formatSelectionEnabler: 启用视频/音频格式选择
|
|
||||||
themeSelect: '主题'
|
|
||||||
languageSelect: '语言'
|
|
||||||
overridesAnchor: 覆盖
|
|
||||||
pathOverrideOption: 启用输出路径覆盖
|
|
||||||
filenameOverrideOption: 启用输出文件名覆盖
|
|
||||||
customFilename: 自定义文件名(留空使用默认值)
|
|
||||||
customPath: 自定义路径
|
|
||||||
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
|
|
||||||
customArgsInput: 自定义 yt-dlp 参数
|
|
||||||
rpcConnErr: 连接 RPC 服务器发生错误
|
|
||||||
splashText: 没有正在进行的下载
|
|
||||||
archiveTitle: 归档
|
|
||||||
clipboardAction: 复制 URL 到剪贴板
|
|
||||||
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
|
|
||||||
restartAppMessage: 需要刷新页面才能生效
|
|
||||||
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
|
|
||||||
newDownloadButton: 新下载
|
|
||||||
homeButtonLabel: 主页
|
|
||||||
archiveButtonLabel: 归档
|
|
||||||
settingsButtonLabel: 设置
|
|
||||||
rpcAuthenticationLabel: RPC 身份验证
|
|
||||||
themeTogglerLabel: 主题切换
|
|
||||||
loadingLabel: 正在加载…
|
|
||||||
appTitle: App 标题
|
|
||||||
savedTemplates: 保存模板
|
|
||||||
templatesEditor: 模板编辑器
|
|
||||||
templatesEditorNameLabel: 模板名称
|
|
||||||
templatesEditorContentLabel: 模板内容
|
|
||||||
logsTitle: '日志'
|
|
||||||
awaitingLogs: '正在等待日志…'
|
|
||||||
bulkDownload: '下载 zip 压缩包中的文件'
|
|
||||||
livestreamURLInput: 直播 URL
|
|
||||||
livestreamStatusWaiting: 等待直播开始
|
|
||||||
livestreamStatusDownloading: 下载中
|
|
||||||
livestreamStatusCompleted: 已完成
|
|
||||||
livestreamStatusErrored: 发生错误
|
|
||||||
livestreamStatusUnknown: 未知
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
本功能将会监控即将开始的直播流,每个进程都会传入参数:--wait-for-video 10 (重试间隔10秒)
|
|
||||||
如果直播已经开始,那么依然可以下载,但是不会记录下载进度。
|
|
||||||
直播开始后,将会转移到下载页面
|
|
||||||
livestreamExperimentalWarning: 实验性功能,可能存在未知Bug,请谨慎使用
|
|
||||||
spanish:
|
|
||||||
urlInput: URL de YouTube u otro servicio compatible
|
|
||||||
statusTitle: Estado
|
|
||||||
startButton: Iniciar
|
|
||||||
statusReady: Listo
|
|
||||||
abortAllButton: Cancelar Todo
|
|
||||||
updateBinButton: Actualizar el binario yt-dlp
|
|
||||||
darkThemeButton: Tema oscuro
|
|
||||||
lightThemeButton: Tema claro
|
|
||||||
settingsAnchor: Ajustes
|
|
||||||
serverAddressTitle: Dirección del servidor
|
|
||||||
serverPortTitle: Puerto
|
|
||||||
extractAudioCheckbox: Extraer audio
|
|
||||||
noMTimeCheckbox: No guardar el tiempo de modificación del archivo
|
|
||||||
bgReminder: Si cierras esta página, la descarga continuará en segundo plano.
|
|
||||||
toastConnected: 'Conectado a'
|
|
||||||
toastUpdated: ¡El binario yt-dlp está actualizado!
|
|
||||||
formatSelectionEnabler: Habilitar la selección de formatos de video/audio
|
|
||||||
themeSelect: 'Tema'
|
|
||||||
languageSelect: 'Idiomas'
|
|
||||||
overridesAnchor: Anulaciones
|
|
||||||
pathOverrideOption: Sobreescribir en la ruta de salida
|
|
||||||
filenameOverrideOption: Sobreescribir el nombre del fichero
|
|
||||||
customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
|
|
||||||
customPath: Ruta personalizada
|
|
||||||
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
|
||||||
customArgsInput: Argumentos yt-dlp personalizados
|
|
||||||
rpcConnErr: Error al conectarse al servidor RPC
|
|
||||||
splashText: No active downloads
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: Copied URL to clipboard
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
russian:
|
|
||||||
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
|
||||||
statusTitle: Статус
|
|
||||||
startButton: Начать
|
|
||||||
statusReady: Готово
|
|
||||||
abortAllButton: Прервать все
|
|
||||||
updateBinButton: Обновить бинарный файл yt-dlp
|
|
||||||
darkThemeButton: Темная тема
|
|
||||||
lightThemeButton: Светлая тема
|
|
||||||
settingsAnchor: Настройки
|
|
||||||
serverAddressTitle: Адрес сервера
|
|
||||||
serverPortTitle: Порт
|
|
||||||
extractAudioCheckbox: Извлечь аудио
|
|
||||||
noMTimeCheckbox: Не устанавливать время модификации файла
|
|
||||||
bgReminder: Как только вы закроете эту страницу, загрузка продолжится в фоновом режиме.
|
|
||||||
toastConnected: 'Подключен к '
|
|
||||||
toastUpdated: Бинарный файл yt-dlp обновлен!
|
|
||||||
formatSelectionEnabler: Активировать выбор видео/аудио форматов
|
|
||||||
themeSelect: 'Тема'
|
|
||||||
languageSelect: 'Язык'
|
|
||||||
overridesAnchor: Переопределить
|
|
||||||
pathOverrideOption: Активировать переопределение выходного пути
|
|
||||||
filenameOverrideOption: Активировать переопределение имени выходного файла
|
|
||||||
customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию)
|
|
||||||
customPath: Задать путь
|
|
||||||
customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность)
|
|
||||||
customArgsInput: Пользовательские аргументы yt-dlp
|
|
||||||
rpcConnErr: Ошибка при подключении к серверу RPC
|
|
||||||
splashText: Нет активных загрузок
|
|
||||||
archiveTitle: Архив
|
|
||||||
clipboardAction: URL скопирован в буфер обмена
|
|
||||||
playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно
|
|
||||||
servedFromReverseProxyCheckbox: Находится за обратным прокси
|
|
||||||
newDownloadButton: Новая загрузка
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Архив
|
|
||||||
settingsButtonLabel: Настройки
|
|
||||||
rpcAuthenticationLabel: RPC-аутентификация
|
|
||||||
themeTogglerLabel: Переключить тему
|
|
||||||
loadingLabel: Загрузка...
|
|
||||||
appTitle: Название приложения
|
|
||||||
savedTemplates: Сохраненные шаблоны
|
|
||||||
templatesEditor: Редактор шаблонов
|
|
||||||
templatesEditorNameLabel: Имя шаблона
|
|
||||||
templatesEditorContentLabel: Содержание шаблона
|
|
||||||
logsTitle: 'Логи'
|
|
||||||
awaitingLogs: 'Ожидание логов...'
|
|
||||||
bulkDownload: 'Скачать файлы в zip архиве'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
korean:
|
|
||||||
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
|
||||||
statusTitle: 상태
|
|
||||||
startButton: 시작
|
|
||||||
statusReady: 준비됨
|
|
||||||
abortAllButton: 모두 중단
|
|
||||||
updateBinButton: yt-dlp 바이너리 업데이트
|
|
||||||
darkThemeButton: 다크 모드
|
|
||||||
lightThemeButton: 라이트 모드
|
|
||||||
settingsAnchor: 설정
|
|
||||||
serverAddressTitle: 서버 주소
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: 오디오 추출
|
|
||||||
noMTimeCheckbox: 파일 수정 시간을 설정하지 않음
|
|
||||||
bgReminder: 이 페이지를 닫아도 백그라운드에서 다운로드가 계속됩니다
|
|
||||||
toastConnected: '다음으로 연결됨 '
|
|
||||||
toastUpdated: yt-dlp 바이너리를 업데이트 했습니다
|
|
||||||
formatSelectionEnabler: 비디오/오디오 포멧 옵션 표시
|
|
||||||
themeSelect: 'Theme'
|
|
||||||
languageSelect: 'Language'
|
|
||||||
overridesAnchor: Overrides
|
|
||||||
pathOverrideOption: Enable output path overriding
|
|
||||||
filenameOverrideOption: Enable output file name overriding
|
|
||||||
customFilename: Custom filename (leave blank to use default)
|
|
||||||
customPath: Custom path
|
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
|
||||||
customArgsInput: Custom yt-dlp arguments
|
|
||||||
rpcConnErr: Error while conencting to RPC server
|
|
||||||
splashText: No active downloads
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: Copied URL to clipboard
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
japanese:
|
|
||||||
urlInput: YouTubeまたはサポート済み動画のURL
|
|
||||||
statusTitle: 状態
|
|
||||||
statusReady: 準備
|
|
||||||
selectFormatButton: フォーマット選択
|
|
||||||
startButton: 開始
|
|
||||||
abortAllButton: すべて中止
|
|
||||||
updateBinButton: yt-dlp更新
|
|
||||||
darkThemeButton: 黒テーマ
|
|
||||||
lightThemeButton: 白テーマ
|
|
||||||
settingsAnchor: 設定
|
|
||||||
serverAddressTitle: サーバーアドレス
|
|
||||||
serverPortTitle: ポート番号
|
|
||||||
extractAudioCheckbox: 音質
|
|
||||||
noMTimeCheckbox: ファイル時間の修正をしない
|
|
||||||
bgReminder: このページを閉じてもバックグラウンドでダウンロードを続けます
|
|
||||||
toastConnected: '接続中 '
|
|
||||||
toastUpdated: yt-dlpを更新しました!
|
|
||||||
formatSelectionEnabler: 選択可能な動画/音源
|
|
||||||
themeSelect: 'テーマ'
|
|
||||||
languageSelect: '言語'
|
|
||||||
overridesAnchor: 上書き
|
|
||||||
pathOverrideOption: 保存するディレクトリ
|
|
||||||
filenameOverrideOption: ファイル名の上書き
|
|
||||||
customFilename: (空白の場合は元のファイル名)
|
|
||||||
customPath: 保存先
|
|
||||||
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
|
||||||
customArgsInput: yt-dlpのオプション
|
|
||||||
rpcConnErr: RPCサーバーへの接続中にエラーが発生しました
|
|
||||||
splashText: アクティブなダウンロードはありません
|
|
||||||
archiveTitle: アーカイブ
|
|
||||||
clipboardAction: URLをクリップボードにコピーしました
|
|
||||||
playlistCheckbox: プレイリストをダウンロード (これには時間がかかりますが、処理中はウィンドウを閉じることができます)
|
|
||||||
servedFromReverseProxyCheckbox: リバースプロキシのサブフォルダにあります
|
|
||||||
newDownloadButton: 新しくダウンロード
|
|
||||||
homeButtonLabel: ホーム
|
|
||||||
archiveButtonLabel: アーカイブ
|
|
||||||
settingsButtonLabel: 設定
|
|
||||||
rpcAuthenticationLabel: RPC認証
|
|
||||||
themeTogglerLabel: テーマ切り替え
|
|
||||||
loadingLabel: 読み込み中...
|
|
||||||
appTitle: アプリタイトル
|
|
||||||
savedTemplates: 保存したテンプレート
|
|
||||||
templatesEditor: テンプレートエディター
|
|
||||||
templatesEditorNameLabel: テンプレート名
|
|
||||||
templatesEditorContentLabel: テンプレート内容
|
|
||||||
logsTitle: 'ログ'
|
|
||||||
awaitingLogs: 'ログを待機中...'
|
|
||||||
bulkDownload: 'ダウンロードしたファイルをZIPで保存'
|
|
||||||
livestreamURLInput: ライブストリームURL
|
|
||||||
livestreamStatusWaiting: 開始を待っています
|
|
||||||
livestreamStatusDownloading: ダウンロード中
|
|
||||||
livestreamStatusCompleted: 完了
|
|
||||||
livestreamStatusErrored: エラー
|
|
||||||
livestreamStatusUnknown: 不明
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
まだ開始されていないライブストリームを監視します。各プロセスは、--wait-for-video 10 で実行されます。
|
|
||||||
すでに開始されているライブストリームが提供された場合、ダウンロードは継続されますが進行状況は追跡されません。
|
|
||||||
ライブストリームが開始されると、ダウンロードページに移動されます。
|
|
||||||
livestreamExperimentalWarning: この機能は実験的なものです。何かが壊れるかもしれません!
|
|
||||||
catalan:
|
|
||||||
urlInput: URL de YouTube o d'un altre servei compatible
|
|
||||||
statusTitle: Estat
|
|
||||||
startButton: Iniciar
|
|
||||||
statusReady: Llest
|
|
||||||
abortAllButton: Cancel·lar Tot
|
|
||||||
updateBinButton: Actualitzar el binari yt-dlp
|
|
||||||
darkThemeButton: Tema fosc
|
|
||||||
lightThemeButton: Tema clar
|
|
||||||
settingsAnchor: Configuració
|
|
||||||
serverAddressTitle: Direcció del servidor
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Extreure àudio
|
|
||||||
noMTimeCheckbox: No guardar el temps de modificació de l'arxiu
|
|
||||||
bgReminder: Si tanques aquesta pàgina, la descàrrega continuarà en segon pla.
|
|
||||||
toastConnected: 'Connectat a'
|
|
||||||
toastUpdated: El binari yt-dlp està actualitzat!
|
|
||||||
formatSelectionEnabler: Habilitar la selecció de formats de vídeo/àudio
|
|
||||||
themeSelect: 'Tema'
|
|
||||||
languageSelect: 'Idiomes'
|
|
||||||
overridesAnchor: Anul·lacions
|
|
||||||
pathOverrideOption: Sobreescriure en la ruta de sortida
|
|
||||||
filenameOverrideOption: Sobreescriure el nom del fitxer
|
|
||||||
customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat)
|
|
||||||
customPath: Ruta personalitzada
|
|
||||||
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
|
|
||||||
customArgsInput: Arguments yt-dlp personalitzats
|
|
||||||
rpcConnErr: Error en connectar-se al servidor RPC
|
|
||||||
splashText: No active downloads
|
|
||||||
archiveTitle: Archive
|
|
||||||
clipboardAction: Copied URL to clipboard
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
ukrainian:
|
|
||||||
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
|
||||||
statusTitle: Статус
|
|
||||||
startButton: Почати
|
|
||||||
statusReady: Готово
|
|
||||||
abortAllButton: Перервати все
|
|
||||||
updateBinButton: Оновити бінарний файл yt-dlp
|
|
||||||
darkThemeButton: Темна тема
|
|
||||||
lightThemeButton: Світла тема
|
|
||||||
settingsAnchor: Налаштування
|
|
||||||
serverAddressTitle: Адреса сервера
|
|
||||||
serverPortTitle: Порт
|
|
||||||
extractAudioCheckbox: Витягти аудіо
|
|
||||||
noMTimeCheckbox: Не встановлювати час модифікації файлу
|
|
||||||
bgReminder: Як тільки ви закриєте цю сторінку, завантаження продовжиться у фоновому режимі.
|
|
||||||
toastConnected: 'Підключений до '
|
|
||||||
toastUpdated: Бінарний файл yt-dlp оновлено!
|
|
||||||
formatSelectionEnabler: Активувати вибір відео/аудіо форматів
|
|
||||||
themeSelect: 'Тема'
|
|
||||||
languageSelect: 'Мова'
|
|
||||||
overridesAnchor: Перевизначити
|
|
||||||
pathOverrideOption: Активувати перевизначення вихідного шляху
|
|
||||||
filenameOverrideOption: Активувати перевизначення імені вихідного файлу
|
|
||||||
customFilename: Введіть ім'я файлу (залишіть порожнім, щоб використовувати значення за замовчуванням)
|
|
||||||
customPath: Задати шлях
|
|
||||||
customArgs: Включити аргументи, що настроюються yt-dlp (велика сила = велика відповідальність)
|
|
||||||
customArgsInput: Користувальницькі аргументи yt-dlp
|
|
||||||
rpcConnErr: Помилка при підключенні до сервера RPC
|
|
||||||
splashText: Немає активних завантажень
|
|
||||||
archiveTitle: Архів
|
|
||||||
clipboardAction: URL скопійовано в буфер обміну
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
polish:
|
|
||||||
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
|
||||||
statusTitle: Status
|
|
||||||
startButton: Początek
|
|
||||||
statusReady: Gotowy
|
|
||||||
abortAllButton: Anuluj wszystko
|
|
||||||
updateBinButton: Zaktualizuj plik binarny yt-dlp
|
|
||||||
darkThemeButton: Ciemny motyw
|
|
||||||
lightThemeButton: Światło motyw
|
|
||||||
settingsAnchor: Ustawienia
|
|
||||||
serverAddressTitle: Adres serwera
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Wyodrębnij dźwięk
|
|
||||||
noMTimeCheckbox: Nie ustawiaj czasu modyfikacji pliku
|
|
||||||
bgReminder: Po zamknięciu tej strony pobieranie będzie kontynuowane w tle.
|
|
||||||
toastConnected: 'Połączony z '
|
|
||||||
toastUpdated: Zaktualizowano plik binarny yt-dlp!
|
|
||||||
formatSelectionEnabler: Aktywuj wybór formatów wideo/audio
|
|
||||||
themeSelect: 'Motyw'
|
|
||||||
languageSelect: 'Język'
|
|
||||||
overridesAnchor: Przedefiniuj
|
|
||||||
pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej
|
|
||||||
filenameOverrideOption: Aktywuj zastępowanie nazwy pliku źródłowego
|
|
||||||
customFilename: Wprowadź nazwę pliku (pozostaw puste, aby użyć nazwy domyślnej)
|
|
||||||
customPath: Ustaw ścieżkę
|
|
||||||
customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność)
|
|
||||||
customArgsInput: Niestandardowe argumenty yt-dlp
|
|
||||||
rpcConnErr: Wystąpił błąd podczas łączenia z serwerem RPC
|
|
||||||
splashText: Brak aktywnych pobrań
|
|
||||||
archiveTitle: Archiwum
|
|
||||||
clipboardAction: Adres URL zostanie skopiowany do schowka
|
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
|
||||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
|
||||||
newDownloadButton: New download
|
|
||||||
homeButtonLabel: Home
|
|
||||||
archiveButtonLabel: Archive
|
|
||||||
settingsButtonLabel: Settings
|
|
||||||
rpcAuthenticationLabel: RPC authentication
|
|
||||||
themeTogglerLabel: Theme toggler
|
|
||||||
loadingLabel: Loading...
|
|
||||||
appTitle: App title
|
|
||||||
savedTemplates: Saved templates
|
|
||||||
templatesEditor: Templates editor
|
|
||||||
templatesEditorNameLabel: Template name
|
|
||||||
templatesEditorContentLabel: Template content
|
|
||||||
logsTitle: 'Logs'
|
|
||||||
awaitingLogs: 'Awaiting logs...'
|
|
||||||
bulkDownload: 'Download files in a zip archive'
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
swedish:
|
|
||||||
urlInput: Videolänk (en per rad)
|
|
||||||
statusTitle: Status
|
|
||||||
statusReady: Redo
|
|
||||||
selectFormatButton: Välj format
|
|
||||||
startButton: Start
|
|
||||||
abortAllButton: Avbryt alla
|
|
||||||
updateBinButton: Uppdatera yt-dlp
|
|
||||||
darkThemeButton: Mörkt tema
|
|
||||||
lightThemeButton: Ljust tema
|
|
||||||
settingsAnchor: Inställningar
|
|
||||||
serverAddressTitle: Serveraddress
|
|
||||||
serverPortTitle: Port
|
|
||||||
extractAudioCheckbox: Extrahera ljud
|
|
||||||
noMTimeCheckbox: Lägg inte till info om när filen senast modifierades
|
|
||||||
bgReminder: När du stänger denna sida så kommer nedladdningen att fortsätta i bakgrunden.
|
|
||||||
toastConnected: 'Ansluten till '
|
|
||||||
toastUpdated: Uppdaterade yt-dlp!
|
|
||||||
formatSelectionEnabler: Tillåt val av ljud- och bildformat
|
|
||||||
themeSelect: 'Tema'
|
|
||||||
languageSelect: 'Språk'
|
|
||||||
overridesAnchor: Överskrivningar
|
|
||||||
pathOverrideOption: Tillåt överskrivning av filsökvägen
|
|
||||||
filenameOverrideOption: Tillåt överskrivning av filnamn
|
|
||||||
customFilename: Eget filnamn (lämna blankt för standardnamn)
|
|
||||||
customPath: Egen filsökväg
|
|
||||||
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
|
|
||||||
customArgsInput: Egna yt-dlp-argument
|
|
||||||
rpcConnErr: Ett fel inträffade vid anslutning till RPC-server
|
|
||||||
splashText: Inga pågående nedladdningar
|
|
||||||
archiveTitle: Arkiv
|
|
||||||
clipboardAction: Kopierade länken
|
|
||||||
playlistCheckbox: Ladda ner spellista (detta kommer ta did, efter start så kan du stänga detta fönster)
|
|
||||||
restartAppMessage: En sidomladdning behövs innan förändringen får effekt
|
|
||||||
servedFromReverseProxyCheckbox: Servern befinner sig bakom en omvänd proxy
|
|
||||||
urlBase: "URL-bas, måste anges när en omvänd proxy används. Standardinställning: lämna blank"
|
|
||||||
newDownloadButton: Ny nedladdning
|
|
||||||
homeButtonLabel: Hem
|
|
||||||
archiveButtonLabel: Arkiv
|
|
||||||
settingsButtonLabel: Inställningar
|
|
||||||
rpcAuthenticationLabel: RPC-Autentisering
|
|
||||||
themeTogglerLabel: Tema-knapp
|
|
||||||
loadingLabel: Laddar...
|
|
||||||
appTitle: Apptitel
|
|
||||||
savedTemplates: Sparade mallar
|
|
||||||
templatesEditor: Mallredigerare
|
|
||||||
templatesEditorNameLabel: Namn
|
|
||||||
templatesEditorContentLabel: Innehåll
|
|
||||||
logsTitle: 'Loggar'
|
|
||||||
awaitingLogs: 'Väntar på loggar...'
|
|
||||||
bulkDownload: 'Ladda ner filer i ett zip-arkiv'
|
|
||||||
rpcPollingTimeTitle: Frekvens av RPC-uppdateringar
|
|
||||||
rpcPollingTimeDescription: En högre frekvens kräver mer CPU-resurser för både server och klient
|
|
||||||
templatesReloadInfo: För att registrera en ny mall så kan en sidomladdning krävas.
|
|
||||||
livestreamURLInput: Livestream URL
|
|
||||||
livestreamStatusWaiting: Waiting/Wait start
|
|
||||||
livestreamStatusDownloading: Downloading
|
|
||||||
livestreamStatusCompleted: Completed
|
|
||||||
livestreamStatusErrored: Errored
|
|
||||||
livestreamStatusUnknown: Unknown
|
|
||||||
livestreamDownloadInfo: |
|
|
||||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
|
||||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
|
||||||
Once started the livestream will be migrated to the downloads page.
|
|
||||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
|
||||||
80
frontend/src/assets/i18n/ca.yaml
Normal file
80
frontend/src/assets/i18n/ca.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL de YouTube o d'un altre servei compatible
|
||||||
|
statusTitle: Estat
|
||||||
|
startButton: Iniciar
|
||||||
|
statusReady: Llest
|
||||||
|
abortAllButton: Cancel·lar Tot
|
||||||
|
updateBinButton: Actualitzar el binari yt-dlp
|
||||||
|
darkThemeButton: Tema fosc
|
||||||
|
lightThemeButton: Tema clar
|
||||||
|
settingsAnchor: Configuració
|
||||||
|
serverAddressTitle: Direcció del servidor
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Extreure àudio
|
||||||
|
noMTimeCheckbox: No guardar el temps de modificació de l'arxiu
|
||||||
|
bgReminder: Si tanques aquesta pàgina, la descàrrega continuarà en segon pla.
|
||||||
|
toastConnected: 'Connectat a'
|
||||||
|
toastUpdated: El binari yt-dlp està actualitzat!
|
||||||
|
formatSelectionEnabler: Habilitar la selecció de formats de vídeo/àudio
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Idiomes'
|
||||||
|
overridesAnchor: Anul·lacions
|
||||||
|
pathOverrideOption: Sobreescriure en la ruta de sortida
|
||||||
|
filenameOverrideOption: Sobreescriure el nom del fitxer
|
||||||
|
autoFileExtensionOption: Afegeix l'extensió de fitxer automàticament
|
||||||
|
customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat)
|
||||||
|
customPath: Ruta personalitzada
|
||||||
|
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
|
||||||
|
customArgsInput: Arguments yt-dlp personalitzats
|
||||||
|
rpcConnErr: Error en connectar-se al servidor RPC
|
||||||
|
splashText: No active downloads
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: Copied URL to clipboard
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'Ajustes generales de descarga'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/de.yaml
Normal file
82
frontend/src/assets/i18n/de.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Video URL
|
||||||
|
statusTitle: Status
|
||||||
|
statusReady: Bereit
|
||||||
|
selectFormatButton: Format auswählen
|
||||||
|
startButton: Start
|
||||||
|
abortAllButton: Alle Abbrechen
|
||||||
|
updateBinButton: yt-dlp Binärdatei aktualisieren
|
||||||
|
darkThemeButton: Dunkler Modus
|
||||||
|
lightThemeButton: Heller Modus
|
||||||
|
settingsAnchor: Einstellungen
|
||||||
|
serverAddressTitle: Adresse des Servers
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Audio extrahieren
|
||||||
|
noMTimeCheckbox: Datei-Änderungszeitpunkt nicht festlegen
|
||||||
|
bgReminder: Sobald Sie diese Seite schließen, wird der Download im Hintergrund fortgesetzt.
|
||||||
|
toastConnected: 'Verbunden mit '
|
||||||
|
toastUpdated: yt-dlp Binärdatei aktualisiert!
|
||||||
|
formatSelectionEnabler: Video/Audio Format auswählbar
|
||||||
|
themeSelect: 'Modus'
|
||||||
|
languageSelect: 'Sprache'
|
||||||
|
overridesAnchor: Überschreibungen
|
||||||
|
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
|
||||||
|
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
|
||||||
|
autoFileExtensionOption: Dateierweiterung automatisch hinzufügen
|
||||||
|
customFilename: Benutzerdefinierter Dateiname (Leer lassen um Standardwert zu nutzen)
|
||||||
|
customPath: Benutzerdefinierter Pfad
|
||||||
|
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (Auf viel Macht folgt große Verantwortung)
|
||||||
|
customArgsInput: Benutzerdefinierte yt-dlp Argumente
|
||||||
|
rpcConnErr: Fehler beim Verbinden mit RPC Server
|
||||||
|
splashText: Keine aktiven Downloads
|
||||||
|
archiveTitle: Archiv
|
||||||
|
clipboardAction: URL in Zwischenablage kopiert
|
||||||
|
playlistCheckbox: Playlist herunterladen (es wird einige Zeit dauern, nach dem Absenden können Sie dieses Fenster schließen)
|
||||||
|
restartAppMessage: Erfordert ein Neuladen der Seite, um wirksam zu werden
|
||||||
|
servedFromReverseProxyCheckbox: Ist hinter einem Reverse Proxy Unterordner
|
||||||
|
newDownloadButton: Neuer Download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archiv
|
||||||
|
settingsButtonLabel: Einstellungen
|
||||||
|
rpcAuthenticationLabel: RPC Authentifizierung
|
||||||
|
themeTogglerLabel: Modus Umschalter
|
||||||
|
loadingLabel: Lädt...
|
||||||
|
appTitle: App Titel
|
||||||
|
savedTemplates: Gespeicherte Vorlage
|
||||||
|
templatesEditor: Vorlageneditor
|
||||||
|
templatesEditorNameLabel: Vorlagen Name
|
||||||
|
templatesEditorContentLabel: Vorlagen Inhalt
|
||||||
|
logsTitle: 'Logausgabe'
|
||||||
|
awaitingLogs: 'Warte auf Log ...'
|
||||||
|
bulkDownload: 'Alles in einem ZIP-Archiv herunterladen'
|
||||||
|
rpcPollingTimeTitle: RPC-Abfragezeit
|
||||||
|
rpcPollingTimeDescription: Ein kürzerer Intervall führt zu einer höheren CPU-Auslastung (Server- und Clientseite)
|
||||||
|
templatesReloadInfo: Um eine neue Vorlage zu registrieren, muss die Seite möglicherweise neu geladen werden.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Warte auf Start
|
||||||
|
livestreamStatusDownloading: Herunterladen
|
||||||
|
livestreamStatusCompleted: Abgeschlossen
|
||||||
|
livestreamStatusErrored: Fehlerhaft
|
||||||
|
livestreamStatusUnknown: Status unbekannt
|
||||||
|
livestreamNoMonitoring: Aktuell wird kein Livestream überwacht
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
Damit wird der noch nicht gestartete Livestream überwacht. Jeder Prozess wird mit --wait-for-video 10 ausgeführt.
|
||||||
|
Wenn ein bereits gestarteter Livestream vorhanden ist, wird er zwar heruntergeladen, aber sein Fortschritt wird nicht verfolgt.
|
||||||
|
Sobald der Livestream gestartet ist, wird er auf der Download-Seite angezeigt.
|
||||||
|
livestreamExperimentalWarning: Dieses Feature ist aktuell noch experimentell, sei vorsichtig, denn es könnte sein, dass etwas nicht genau funktioniert!
|
||||||
|
accentSelect: 'Farbtöne'
|
||||||
|
urlBase: URL-Basis für Reverse-Proxy-Unterstützung (Unterverzeichnis), standardmäßig leer
|
||||||
|
generalDownloadSettings: 'Allgemeine Download Einstellungen'
|
||||||
|
deleteCookies: 'Cookies löschen'
|
||||||
|
noFilesFound: 'Keine Dateien gefunden'
|
||||||
|
tableView: 'Tabellenansicht'
|
||||||
|
deleteSelected: 'Ausgewählte löschen'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
86
frontend/src/assets/i18n/en_US.yaml
Normal file
86
frontend/src/assets/i18n/en_US.yaml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Video URL (one per line)
|
||||||
|
statusTitle: Status
|
||||||
|
statusReady: Ready
|
||||||
|
selectFormatButton: Select format
|
||||||
|
startButton: Start
|
||||||
|
abortAllButton: Abort All
|
||||||
|
updateBinButton: Update yt-dlp binary
|
||||||
|
darkThemeButton: Dark theme
|
||||||
|
lightThemeButton: Light theme
|
||||||
|
settingsAnchor: Settings
|
||||||
|
serverAddressTitle: Server address
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Extract audio
|
||||||
|
noMTimeCheckbox: Don't set file modification time
|
||||||
|
bgReminder: Once you close this page the download will continue in the background.
|
||||||
|
toastConnected: 'Connected to '
|
||||||
|
toastUpdated: Updated yt-dlp binary!
|
||||||
|
formatSelectionEnabler: Enable video/audio formats selection
|
||||||
|
themeSelect: 'Theme'
|
||||||
|
languageSelect: 'Language'
|
||||||
|
overridesAnchor: Overrides
|
||||||
|
pathOverrideOption: Enable output path overriding
|
||||||
|
filenameOverrideOption: Enable output file name overriding
|
||||||
|
autoFileExtensionOption: Automatically add file extension
|
||||||
|
customFilename: Custom filename (leave blank to use default)
|
||||||
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
|
rpcConnErr: Error while connecting to RPC server
|
||||||
|
splashText: No active downloads
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: Copied URL to clipboard
|
||||||
|
playlistCheckbox: Download playlist
|
||||||
|
restartAppMessage: Needs a page reload to take effect
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
|
clearCompletedButton: 'Clear completed'
|
||||||
|
twitchIntegrationInfo: |
|
||||||
|
To enable monitoring Twitch streams follow this wiki page.
|
||||||
|
https://github.com/marcopiovanello/yt-dlp-web-ui/wiki/Twitch-integration
|
||||||
80
frontend/src/assets/i18n/es.yaml
Normal file
80
frontend/src/assets/i18n/es.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL de YouTube u otro servicio compatible
|
||||||
|
statusTitle: Estado
|
||||||
|
startButton: Iniciar
|
||||||
|
statusReady: Listo
|
||||||
|
abortAllButton: Cancelar Todo
|
||||||
|
updateBinButton: Actualizar el binario yt-dlp
|
||||||
|
darkThemeButton: Tema oscuro
|
||||||
|
lightThemeButton: Tema claro
|
||||||
|
settingsAnchor: Ajustes
|
||||||
|
serverAddressTitle: Dirección del servidor
|
||||||
|
serverPortTitle: Puerto
|
||||||
|
extractAudioCheckbox: Extraer audio
|
||||||
|
noMTimeCheckbox: No guardar el tiempo de modificación del archivo
|
||||||
|
bgReminder: Si cierras esta página, la descarga continuará en segundo plano.
|
||||||
|
toastConnected: 'Conectado a'
|
||||||
|
toastUpdated: ¡El binario yt-dlp está actualizado!
|
||||||
|
formatSelectionEnabler: Habilitar la selección de formatos de video/audio
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Idiomas'
|
||||||
|
overridesAnchor: Anulaciones
|
||||||
|
pathOverrideOption: Sobreescribir en la ruta de salida
|
||||||
|
filenameOverrideOption: Sobreescribir el nombre del fichero
|
||||||
|
autoFileExtensionOption: Agregar extensión de archivo automáticamente
|
||||||
|
customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
|
||||||
|
customPath: Ruta personalizada
|
||||||
|
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
||||||
|
customArgsInput: Argumentos yt-dlp personalizados
|
||||||
|
rpcConnErr: Error al conectarse al servidor RPC
|
||||||
|
splashText: No active downloads
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: Copied URL to clipboard
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
84
frontend/src/assets/i18n/fr.yaml
Normal file
84
frontend/src/assets/i18n/fr.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
||||||
|
statusTitle: Statut
|
||||||
|
statusReady: Prêt
|
||||||
|
selectFormatButton: Sélectionner le format
|
||||||
|
startButton: Démarrer
|
||||||
|
abortAllButton: Tout arrêter
|
||||||
|
updateBinButton: Mettre à jour l'exécutable yt-dlp
|
||||||
|
darkThemeButton: Thème sombre
|
||||||
|
lightThemeButton: Thème clair
|
||||||
|
settingsAnchor: Paramètres
|
||||||
|
serverAddressTitle: Adresse du serveur
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Extraire l'audio
|
||||||
|
noMTimeCheckbox: Ne pas définir le temps de modification du fichier
|
||||||
|
bgReminder: Une fois que vous aurez fermé cette page, le téléchargement continuera en arrière-plan.
|
||||||
|
toastConnected: 'Connecté à '
|
||||||
|
toastUpdated: L'exécutable yt-dlp a été mis à jour !
|
||||||
|
formatSelectionEnabler: Activer la sélection des formats vidéo/audio
|
||||||
|
themeSelect: 'Thème'
|
||||||
|
languageSelect: 'Langue'
|
||||||
|
overridesAnchor: Remplacer
|
||||||
|
pathOverrideOption: Activer le remplacement du chemin de sortie
|
||||||
|
filenameOverrideOption: Activer le remplacement du nom du fichier de sortie
|
||||||
|
autoFileExtensionOption: Ajouter automatiquement l'extension de fichier
|
||||||
|
customFilename: Nom de fichier personnalisé (laisser vide pour utiliser le nom par défaut)
|
||||||
|
customPath: Chemin personnalisé
|
||||||
|
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
|
||||||
|
customArgsInput: Arguments yt-dlp personnalisés
|
||||||
|
rpcConnErr: Erreur lors de la connexion au serveur RPC
|
||||||
|
splashText: Aucun téléchargement actif
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: URL copiée dans le presse-papiers
|
||||||
|
playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée)
|
||||||
|
restartAppMessage: Nécessite un rechargement de la page pour prendre effet
|
||||||
|
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
|
||||||
|
notConnectedText: not connected
|
||||||
|
settingsLabel: Settings
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: Nom de l'application
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/hu.yaml
Normal file
82
frontend/src/assets/i18n/hu.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Video URL (soronként egy)
|
||||||
|
statusTitle: Állapot
|
||||||
|
statusReady: Előkészítve
|
||||||
|
selectFormatButton: Válassz formátumot
|
||||||
|
startButton: Indítás
|
||||||
|
abortAllButton: Összes megszakítása
|
||||||
|
updateBinButton: yt-dlp bináris frissítése
|
||||||
|
darkThemeButton: Sötét téma
|
||||||
|
lightThemeButton: Világos téma
|
||||||
|
settingsAnchor: Beállítások
|
||||||
|
serverAddressTitle: Szerver címe
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Audio konvertálása
|
||||||
|
noMTimeCheckbox: Fájl módosítás időpontja ne legyen beállítva
|
||||||
|
bgReminder: Miután a lap bezárásra kerül, a letöltés folytatódni fog a háttérben.
|
||||||
|
toastConnected: 'Kapcsolódva: '
|
||||||
|
toastUpdated: yt-dlp bináris frissítése sikeres volt!
|
||||||
|
formatSelectionEnabler: Video/audio formátum manuális kiválasztásának engedélyezése
|
||||||
|
themeSelect: 'Téma'
|
||||||
|
languageSelect: 'Nyelv'
|
||||||
|
overridesAnchor: Felülbírálások
|
||||||
|
pathOverrideOption: Letöltési útvonal felülbírálása
|
||||||
|
filenameOverrideOption: Letöltési fájlnév felülbírálása
|
||||||
|
autoFileExtensionOption: Automatikus fájlkiterjesztés
|
||||||
|
customFilename: Egyedi fájlnév (hagyd üresen, hogy a fájlnév automatikusan generálódjon)
|
||||||
|
customPath: Egyedi útvonal
|
||||||
|
customArgs: Egyedi yt-dlp argumentumok (Nagy hatalommal nagy felelősség jár.)
|
||||||
|
customArgsInput: Egyedi yt-dlp argumentumok
|
||||||
|
rpcConnErr: Hiba történt az RPC szerver történő kapcsolódáskor
|
||||||
|
splashText: Nincs aktív letöltés
|
||||||
|
archiveTitle: Archívum
|
||||||
|
clipboardAction: URL a vágólapra másolva.
|
||||||
|
playlistCheckbox: Lejátszási lista letöltése (Több időt vehet igénybe. A letöltés a háttérben történik, a böngészőablak szabadon bezárható.)
|
||||||
|
restartAppMessage: Az oldal újratöltése lehet szükséges a változtatások megjelenítéséhez.
|
||||||
|
servedFromReverseProxyCheckbox: Reverse proxy mögötti működés
|
||||||
|
urlBase: URL base, reverse proxy támogatásához (subdir), alapból üres
|
||||||
|
newDownloadButton: Új letöltés
|
||||||
|
homeButtonLabel: Kezdőlap
|
||||||
|
archiveButtonLabel: Archívum
|
||||||
|
settingsButtonLabel: Beállítások
|
||||||
|
rpcAuthenticationLabel: RPC bejelentkezés
|
||||||
|
themeTogglerLabel: Témaválasztó
|
||||||
|
loadingLabel: Betöltés...
|
||||||
|
appTitle: Alkalmazás címe
|
||||||
|
savedTemplates: Mentett sablonok
|
||||||
|
templatesEditor: Sablonszerkesztő
|
||||||
|
templatesEditorNameLabel: Sablon neve
|
||||||
|
templatesEditorContentLabel: Sablon tartalma
|
||||||
|
logsTitle: 'Naplók'
|
||||||
|
awaitingLogs: 'Napló letöltése...'
|
||||||
|
bulkDownload: 'Fájlok letöltése ZIP archívumként'
|
||||||
|
rpcPollingTimeTitle: RPC lekérdezési időköz
|
||||||
|
rpcPollingTimeDescription: Rövidebb időköz nagyobb processzor terheléssel járhat (mind szerver és böngésző oldalon is)
|
||||||
|
templatesReloadInfo: Az új sablon megjelenéséhez újra kell tölteni az oldalt.
|
||||||
|
livestreamURLInput: Élő stream URL
|
||||||
|
livestreamStatusWaiting: Várakozás a kezdésre
|
||||||
|
livestreamStatusDownloading: Letöltés
|
||||||
|
livestreamStatusCompleted: Letöltve
|
||||||
|
livestreamStatusErrored: Hiba
|
||||||
|
livestreamStatusUnknown: Ismeretlen
|
||||||
|
livestreamNoMonitoring: Nincsenek figyelt élő adások
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
Ez figyelni fog egy még el nem indított élő közvetítést. Minden folyamat a --wait-for-video 10 paraméterrel lesz végrehajtva.
|
||||||
|
Ha egy már elindított élő közvetítés van megadva, az továbbra is letöltésre kerül, de a folyamatát nem követi nyomon.
|
||||||
|
Amint elindul, az élő közvetítés átkerül a letöltések oldalra..
|
||||||
|
livestreamExperimentalWarning: Ez a funkció még kísérleti. Nem garantált a hibamentes működés.
|
||||||
|
accentSelect: 'Kiemelt szín'
|
||||||
|
generalDownloadSettings: 'Általános letöltési beállítások'
|
||||||
|
deleteCookies: Sütik törlése
|
||||||
|
noFilesFound: 'Nem található fájlok'
|
||||||
|
tableView: 'Táblázatos Nézet'
|
||||||
|
deleteSelected: 'Kiválasztottak törlése'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/it_IT.yaml
Normal file
82
frontend/src/assets/i18n/it_IT.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL Video (uno per linea)
|
||||||
|
statusTitle: Stato
|
||||||
|
statusReady: Pronto
|
||||||
|
selectFormatButton: Seziona formato
|
||||||
|
startButton: Inizia
|
||||||
|
abortAllButton: Termina tutto
|
||||||
|
updateBinButton: Aggiorna yt-dlp
|
||||||
|
darkThemeButton: Tema scuro
|
||||||
|
lightThemeButton: Tema chiaro
|
||||||
|
settingsAnchor: Impostazioni
|
||||||
|
serverAddressTitle: Indirizzo server
|
||||||
|
serverPortTitle: Porta
|
||||||
|
extractAudioCheckbox: Estrai l'audio
|
||||||
|
noMTimeCheckbox: Non impostare la proprietà "Data ultima modifica"
|
||||||
|
bgReminder: Chiusa questa UI il download continuerà in background.
|
||||||
|
toastConnected: 'Connesso a '
|
||||||
|
toastUpdated: yt-dlp aggiornato con successo!
|
||||||
|
formatSelectionEnabler: Abilita la selezione dei formati audio/video
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Lingua'
|
||||||
|
overridesAnchor: Sovrascritture
|
||||||
|
pathOverrideOption: Abilita sovrascrittura percorso di output
|
||||||
|
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
||||||
|
autoFileExtensionOption: Aggiungi estensione automaticamente
|
||||||
|
customFilename: Nome file personalizzato (lascia vuoto per utilizzare quello predefinito)
|
||||||
|
customPath: Percorso personalizzato
|
||||||
|
customArgs: Abilita argomenti yt-dlp personalizzati (grande potere = grandi responsabilità)
|
||||||
|
customArgsInput: Argomenti yt-dlp personalizzati
|
||||||
|
rpcConnErr: Errore nella connessione al server RPC
|
||||||
|
splashText: Nessun download attivo
|
||||||
|
archiveTitle: Archivio
|
||||||
|
clipboardAction: URL copiato negli appunti
|
||||||
|
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
|
||||||
|
restartAppMessage: La finestra deve essere ricaricata affinché abbia effetto
|
||||||
|
servedFromReverseProxyCheckbox: È dietro un reverse proxy
|
||||||
|
urlBase: base URL, per supporto a reverse proxy (subdir), default vuoto
|
||||||
|
newDownloadButton: Nuovo download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archivio
|
||||||
|
settingsButtonLabel: Impostazioni
|
||||||
|
rpcAuthenticationLabel: Autenticazione RPC
|
||||||
|
themeTogglerLabel: Selettore Tema
|
||||||
|
loadingLabel: Caricamento...
|
||||||
|
appTitle: Titolo applicazione
|
||||||
|
savedTemplates: Modelli salvati
|
||||||
|
templatesEditor: Editor modelli
|
||||||
|
templatesEditorNameLabel: Nome modello
|
||||||
|
templatesEditorContentLabel: Contenuto del modello
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Scaricare i file in un archivio zip'
|
||||||
|
rpcPollingTimeTitle: Intervallo di polling RPC
|
||||||
|
rpcPollingTimeDescription: Un intervallo più corto implica un maggior utilizzo di CPU (lato client e server)
|
||||||
|
templatesReloadInfo: Per registrare un nuovo modello potrebbe essere necessario ricaricare la pagina.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Attesa inizio
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completato
|
||||||
|
livestreamStatusErrored: Errore
|
||||||
|
livestreamStatusUnknown: Sconosciuto
|
||||||
|
livestreamNoMonitoring: Nessun livestream monitorato
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
Questo monitorerà il livestream ancora da avviare. Ogni processo verrà eseguito con --wait-for-video 10.
|
||||||
|
Se viene fornito un livestream già avviato, questo verrà comunque scaricato, ma il suo progresso non verrà monitorato.
|
||||||
|
Una volta avviato, il livestream verrà migrato nella pagina dei download.
|
||||||
|
livestreamExperimentalWarning: Questa funzione è ancora sperimentale. Qualcosa potrebbe rompersi!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
generalDownloadSettings: 'Impostazioni generali di download'
|
||||||
|
deleteCookies: Elimina Cookies
|
||||||
|
noFilesFound: 'Nessun file trovato'
|
||||||
|
tableView: 'Vista Tabella'
|
||||||
|
deleteSelected: 'Elimina selezionati'
|
||||||
|
subscriptionsButtonLabel: 'Abbonamenti'
|
||||||
|
subscriptionsEmptyLabel: 'Nessuna iscrizione'
|
||||||
|
subscriptionsURLInput: 'URL Canale'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Iscrive a un canale definito. Verrà scaricato solo l'ultimo video.
|
||||||
|
Il lavoro di monitoraggio sarà programmato/attivato da un'espressione cron definita (se lasciata vuota, l'impostazione predefinita è ogni 5 minuti).
|
||||||
|
cronExpressionLabel: 'Espressione Cron'
|
||||||
|
editButtonLabel: 'Modifica'
|
||||||
|
newSubscriptionButton: Nuova iscrizione
|
||||||
81
frontend/src/assets/i18n/ja.yaml
Normal file
81
frontend/src/assets/i18n/ja.yaml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: YouTubeまたはサポート済み動画のURL
|
||||||
|
statusTitle: 状態
|
||||||
|
statusReady: 準備
|
||||||
|
selectFormatButton: フォーマット選択
|
||||||
|
startButton: 開始
|
||||||
|
abortAllButton: すべて中止
|
||||||
|
updateBinButton: yt-dlp更新
|
||||||
|
darkThemeButton: 黒テーマ
|
||||||
|
lightThemeButton: 白テーマ
|
||||||
|
settingsAnchor: 設定
|
||||||
|
serverAddressTitle: サーバーアドレス
|
||||||
|
serverPortTitle: ポート番号
|
||||||
|
extractAudioCheckbox: 音質
|
||||||
|
noMTimeCheckbox: ファイル時間の修正をしない
|
||||||
|
bgReminder: このページを閉じてもバックグラウンドでダウンロードを続けます
|
||||||
|
toastConnected: '接続中 '
|
||||||
|
toastUpdated: yt-dlpを更新しました!
|
||||||
|
formatSelectionEnabler: 選択可能な動画/音源
|
||||||
|
themeSelect: 'テーマ'
|
||||||
|
languageSelect: '言語'
|
||||||
|
overridesAnchor: 上書き
|
||||||
|
pathOverrideOption: 保存するディレクトリ
|
||||||
|
filenameOverrideOption: ファイル名の上書き
|
||||||
|
autoFileExtensionOption: 自動ファイル拡張子
|
||||||
|
customFilename: (空白の場合は元のファイル名)
|
||||||
|
customPath: 保存先
|
||||||
|
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
||||||
|
customArgsInput: yt-dlpのオプション
|
||||||
|
rpcConnErr: RPCサーバーへの接続中にエラーが発生しました
|
||||||
|
splashText: アクティブなダウンロードはありません
|
||||||
|
archiveTitle: アーカイブ
|
||||||
|
clipboardAction: URLをクリップボードにコピーしました
|
||||||
|
playlistCheckbox: プレイリストをダウンロード (これには時間がかかりますが、処理中はウィンドウを閉じることができます)
|
||||||
|
servedFromReverseProxyCheckbox: リバースプロキシのサブフォルダにあります
|
||||||
|
newDownloadButton: 新しくダウンロード
|
||||||
|
homeButtonLabel: ホーム
|
||||||
|
archiveButtonLabel: アーカイブ
|
||||||
|
settingsButtonLabel: 設定
|
||||||
|
rpcAuthenticationLabel: RPC認証
|
||||||
|
themeTogglerLabel: テーマ切り替え
|
||||||
|
loadingLabel: 読み込み中...
|
||||||
|
appTitle: アプリタイトル
|
||||||
|
savedTemplates: 保存したテンプレート
|
||||||
|
templatesEditor: テンプレートエディター
|
||||||
|
templatesEditorNameLabel: テンプレート名
|
||||||
|
templatesEditorContentLabel: テンプレート内容
|
||||||
|
logsTitle: 'ログ'
|
||||||
|
awaitingLogs: 'ログを待機中...'
|
||||||
|
bulkDownload: 'ダウンロードしたファイルをZIPで保存'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: ライブストリームURL
|
||||||
|
livestreamStatusWaiting: 開始を待っています
|
||||||
|
livestreamStatusDownloading: ダウンロード中
|
||||||
|
livestreamStatusCompleted: 完了
|
||||||
|
livestreamStatusErrored: エラー
|
||||||
|
livestreamStatusUnknown: 不明
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
まだ開始されていないライブストリームを監視します。各プロセスは、--wait-for-video 10 で実行されます。
|
||||||
|
すでに開始されているライブストリームが提供された場合、ダウンロードは継続されますが進行状況は追跡されません。
|
||||||
|
ライブストリームが開始されると、ダウンロードページに移動されます。
|
||||||
|
livestreamExperimentalWarning: この機能は実験的なものです。何かが壊れるかもしれません!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
80
frontend/src/assets/i18n/ko.yaml
Normal file
80
frontend/src/assets/i18n/ko.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
||||||
|
statusTitle: 상태
|
||||||
|
startButton: 시작
|
||||||
|
statusReady: 준비됨
|
||||||
|
abortAllButton: 모두 중단
|
||||||
|
updateBinButton: yt-dlp 바이너리 업데이트
|
||||||
|
darkThemeButton: 다크 모드
|
||||||
|
lightThemeButton: 라이트 모드
|
||||||
|
settingsAnchor: 설정
|
||||||
|
serverAddressTitle: 서버 주소
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: 오디오 추출
|
||||||
|
noMTimeCheckbox: 파일 수정 시간을 설정하지 않음
|
||||||
|
bgReminder: 이 페이지를 닫아도 백그라운드에서 다운로드가 계속됩니다
|
||||||
|
toastConnected: '다음으로 연결됨 '
|
||||||
|
toastUpdated: yt-dlp 바이너리를 업데이트 했습니다
|
||||||
|
formatSelectionEnabler: 비디오/오디오 포멧 옵션 표시
|
||||||
|
themeSelect: 'Theme'
|
||||||
|
languageSelect: 'Language'
|
||||||
|
overridesAnchor: Overrides
|
||||||
|
pathOverrideOption: Enable output path overriding
|
||||||
|
filenameOverrideOption: Enable output file name overriding
|
||||||
|
autoFileExtensionOption: 자동으로 파일 확장자 추가
|
||||||
|
customFilename: Custom filename (leave blank to use default)
|
||||||
|
customPath: Custom path
|
||||||
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
customArgsInput: Custom yt-dlp arguments
|
||||||
|
rpcConnErr: Error while conencting to RPC server
|
||||||
|
splashText: No active downloads
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: Copied URL to clipboard
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
80
frontend/src/assets/i18n/pl.yaml
Normal file
80
frontend/src/assets/i18n/pl.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
||||||
|
statusTitle: Status
|
||||||
|
startButton: Początek
|
||||||
|
statusReady: Gotowy
|
||||||
|
abortAllButton: Anuluj wszystko
|
||||||
|
updateBinButton: Zaktualizuj plik binarny yt-dlp
|
||||||
|
darkThemeButton: Ciemny motyw
|
||||||
|
lightThemeButton: Światło motyw
|
||||||
|
settingsAnchor: Ustawienia
|
||||||
|
serverAddressTitle: Adres serwera
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Wyodrębnij dźwięk
|
||||||
|
noMTimeCheckbox: Nie ustawiaj czasu modyfikacji pliku
|
||||||
|
bgReminder: Po zamknięciu tej strony pobieranie będzie kontynuowane w tle.
|
||||||
|
toastConnected: 'Połączony z '
|
||||||
|
toastUpdated: Zaktualizowano plik binarny yt-dlp!
|
||||||
|
formatSelectionEnabler: Aktywuj wybór formatów wideo/audio
|
||||||
|
themeSelect: 'Motyw'
|
||||||
|
languageSelect: 'Język'
|
||||||
|
overridesAnchor: Przedefiniuj
|
||||||
|
pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej
|
||||||
|
filenameOverrideOption: Aktywuj zastępowanie nazwy pliku źródłowego
|
||||||
|
autoFileExtensionOption: Automatyczne rozszerzenie pliku
|
||||||
|
customFilename: Wprowadź nazwę pliku (pozostaw puste, aby użyć nazwy domyślnej)
|
||||||
|
customPath: Ustaw ścieżkę
|
||||||
|
customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność)
|
||||||
|
customArgsInput: Niestandardowe argumenty yt-dlp
|
||||||
|
rpcConnErr: Wystąpił błąd podczas łączenia z serwerem RPC
|
||||||
|
splashText: Brak aktywnych pobrań
|
||||||
|
archiveTitle: Archiwum
|
||||||
|
clipboardAction: Adres URL zostanie skopiowany do schowka
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/pt_BR.yaml
Normal file
82
frontend/src/assets/i18n/pt_BR.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL do vídeo (uma por linha)
|
||||||
|
statusTitle: Status
|
||||||
|
statusReady: Pronto
|
||||||
|
selectFormatButton: Selecionar formato
|
||||||
|
startButton: Iniciar
|
||||||
|
abortAllButton: Cancelar tudo
|
||||||
|
updateBinButton: Atualizar binário yt-dlp
|
||||||
|
darkThemeButton: Tema escuro
|
||||||
|
lightThemeButton: Tema claro
|
||||||
|
settingsAnchor: Configurações
|
||||||
|
serverAddressTitle: Endereço do servidor
|
||||||
|
serverPortTitle: Porta
|
||||||
|
extractAudioCheckbox: Extrair áudio
|
||||||
|
noMTimeCheckbox: Não definir hora de modificação do arquivo
|
||||||
|
bgReminder: Uma vez que você feche esta página, o download continuará em segundo plano.
|
||||||
|
toastConnected: 'Conectado a '
|
||||||
|
toastUpdated: Binário yt-dlp atualizado!
|
||||||
|
formatSelectionEnabler: Habilitar seleção de formatos de vídeo/aúdio
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Idioma'
|
||||||
|
overridesAnchor: Substituições
|
||||||
|
pathOverrideOption: Habilitar substituição do caminho de saída
|
||||||
|
filenameOverrideOption: Habilitar substituição do nome do arquivo de saída
|
||||||
|
autoFileExtensionOption: Adicionar extensão de arquivo automaticamente
|
||||||
|
customFilename: Nome de arquivo personalizado (deixe em branco para usar o padrão)
|
||||||
|
customPath: Caminho personalizado
|
||||||
|
customArgs: Habilitar argumentos personalizados do yt-dlp (grandes poderes = grandes responsabilidades)
|
||||||
|
customArgsInput: Argumentos personalizados do yt-dlp
|
||||||
|
rpcConnErr: Erro ao conectar ao servidor RPC
|
||||||
|
splashText: Nenhum download ativo
|
||||||
|
archiveTitle: Arquivo
|
||||||
|
clipboardAction: URL copiada para a área de transferência
|
||||||
|
playlistCheckbox: Baixar playlist (isso pode levar algum tempo, depois de enviar você pode fechar esta janela)
|
||||||
|
restartAppMessage: Necessário recarregar a página para que a mudança tenha efeito
|
||||||
|
servedFromReverseProxyCheckbox: Está atrás de um proxy reverso
|
||||||
|
urlBase: Base da URL, para suporte de proxy reverso (subdiretório), padrão vazio
|
||||||
|
newDownloadButton: Novo download
|
||||||
|
homeButtonLabel: Início
|
||||||
|
archiveButtonLabel: Arquivo
|
||||||
|
settingsButtonLabel: Configurações
|
||||||
|
rpcAuthenticationLabel: Autenticação RPC
|
||||||
|
themeTogglerLabel: Alternador de tema
|
||||||
|
loadingLabel: Carregando...
|
||||||
|
appTitle: Título do aplicativo
|
||||||
|
savedTemplates: Modelos salvos
|
||||||
|
templatesEditor: Editor de modelos
|
||||||
|
templatesEditorNameLabel: Nome do modelo
|
||||||
|
templatesEditorContentLabel: Conteúdo do modelo
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Aguardando logs...'
|
||||||
|
bulkDownload: 'Baixar arquivos em um arquivo zip'
|
||||||
|
rpcPollingTimeTitle: Tempo de polling RPC
|
||||||
|
rpcPollingTimeDescription: Um intervalo menor resulta em maior uso de CPU (lado do servidor e do cliente)
|
||||||
|
templatesReloadInfo: Para registrar um novo modelo, pode ser necessário recarregar a página.
|
||||||
|
livestreamURLInput: URL da transmissão ao vivo
|
||||||
|
livestreamStatusWaiting: Aguardando/Aguarde o início
|
||||||
|
livestreamStatusDownloading: Baixando
|
||||||
|
livestreamStatusCompleted: Concluído
|
||||||
|
livestreamStatusErrored: Erro
|
||||||
|
livestreamStatusUnknown: Desconhecido
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
Isso monitorará uma transmissão ao vivo que ainda não começou. Cada processo será executado com --wait-for-video 10.
|
||||||
|
Se uma transmissão ao vivo já iniciada for fornecida, ela ainda será baixada, mas seu progresso não será rastreado.
|
||||||
|
Uma vez iniciada, a transmissão ao vivo será migrada para a página de downloads.
|
||||||
|
livestreamExperimentalWarning: Este recurso ainda é experimental. Algo pode quebrar!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
80
frontend/src/assets/i18n/ru.yaml
Normal file
80
frontend/src/assets/i18n/ru.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
||||||
|
statusTitle: Статус
|
||||||
|
startButton: Начать
|
||||||
|
statusReady: Готово
|
||||||
|
abortAllButton: Прервать все
|
||||||
|
updateBinButton: Обновить бинарный файл yt-dlp
|
||||||
|
darkThemeButton: Темная тема
|
||||||
|
lightThemeButton: Светлая тема
|
||||||
|
settingsAnchor: Настройки
|
||||||
|
serverAddressTitle: Адрес сервера
|
||||||
|
serverPortTitle: Порт
|
||||||
|
extractAudioCheckbox: Извлечь аудио
|
||||||
|
noMTimeCheckbox: Не устанавливать время модификации файла
|
||||||
|
bgReminder: Как только вы закроете эту страницу, загрузка продолжится в фоновом режиме.
|
||||||
|
toastConnected: 'Подключен к '
|
||||||
|
toastUpdated: Бинарный файл yt-dlp обновлен!
|
||||||
|
formatSelectionEnabler: Активировать выбор видео/аудио форматов
|
||||||
|
themeSelect: 'Тема'
|
||||||
|
languageSelect: 'Язык'
|
||||||
|
overridesAnchor: Переопределить
|
||||||
|
pathOverrideOption: Активировать переопределение выходного пути
|
||||||
|
filenameOverrideOption: Активировать переопределение имени выходного файла
|
||||||
|
autoFileExtensionOption: Автоматическое расширение файла
|
||||||
|
customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию)
|
||||||
|
customPath: Задать путь
|
||||||
|
customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность)
|
||||||
|
customArgsInput: Пользовательские аргументы yt-dlp
|
||||||
|
rpcConnErr: Ошибка при подключении к серверу RPC
|
||||||
|
splashText: Нет активных загрузок
|
||||||
|
archiveTitle: Архив
|
||||||
|
clipboardAction: URL скопирован в буфер обмена
|
||||||
|
playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно
|
||||||
|
servedFromReverseProxyCheckbox: Находится за обратным прокси
|
||||||
|
newDownloadButton: Новая загрузка
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Архив
|
||||||
|
settingsButtonLabel: Настройки
|
||||||
|
rpcAuthenticationLabel: RPC-аутентификация
|
||||||
|
themeTogglerLabel: Переключить тему
|
||||||
|
loadingLabel: Загрузка...
|
||||||
|
appTitle: Название приложения
|
||||||
|
savedTemplates: Сохраненные шаблоны
|
||||||
|
templatesEditor: Редактор шаблонов
|
||||||
|
templatesEditorNameLabel: Имя шаблона
|
||||||
|
templatesEditorContentLabel: Содержание шаблона
|
||||||
|
logsTitle: 'Логи'
|
||||||
|
awaitingLogs: 'Ожидание логов...'
|
||||||
|
bulkDownload: 'Скачать файлы в zip архиве'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/sv.yaml
Normal file
82
frontend/src/assets/i18n/sv.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: Videolänk (en per rad)
|
||||||
|
statusTitle: Status
|
||||||
|
statusReady: Redo
|
||||||
|
selectFormatButton: Välj format
|
||||||
|
startButton: Start
|
||||||
|
abortAllButton: Avbryt alla
|
||||||
|
updateBinButton: Uppdatera yt-dlp
|
||||||
|
darkThemeButton: Mörkt tema
|
||||||
|
lightThemeButton: Ljust tema
|
||||||
|
settingsAnchor: Inställningar
|
||||||
|
serverAddressTitle: Serveraddress
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Extrahera ljud
|
||||||
|
noMTimeCheckbox: Lägg inte till info om när filen senast modifierades
|
||||||
|
bgReminder: När du stänger denna sida så kommer nedladdningen att fortsätta i bakgrunden.
|
||||||
|
toastConnected: 'Ansluten till '
|
||||||
|
toastUpdated: Uppdaterade yt-dlp!
|
||||||
|
formatSelectionEnabler: Tillåt val av ljud- och bildformat
|
||||||
|
themeSelect: 'Tema'
|
||||||
|
languageSelect: 'Språk'
|
||||||
|
overridesAnchor: Överskrivningar
|
||||||
|
pathOverrideOption: Tillåt överskrivning av filsökvägen
|
||||||
|
filenameOverrideOption: Tillåt överskrivning av filnamn
|
||||||
|
autoFileExtensionOption: Lägg till filändelse automatiskt
|
||||||
|
customFilename: Eget filnamn (lämna blankt för standardnamn)
|
||||||
|
customPath: Egen filsökväg
|
||||||
|
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
|
||||||
|
customArgsInput: Egna yt-dlp-argument
|
||||||
|
rpcConnErr: Ett fel inträffade vid anslutning till RPC-server
|
||||||
|
splashText: Inga pågående nedladdningar
|
||||||
|
archiveTitle: Arkiv
|
||||||
|
clipboardAction: Kopierade länken
|
||||||
|
playlistCheckbox: Ladda ner spellista (detta kommer ta did, efter start så kan du stänga detta fönster)
|
||||||
|
restartAppMessage: En sidomladdning behövs innan förändringen får effekt
|
||||||
|
servedFromReverseProxyCheckbox: Servern befinner sig bakom en omvänd proxy
|
||||||
|
urlBase: "URL-bas, måste anges när en omvänd proxy används. Standardinställning: lämna blank"
|
||||||
|
newDownloadButton: Ny nedladdning
|
||||||
|
homeButtonLabel: Hem
|
||||||
|
archiveButtonLabel: Arkiv
|
||||||
|
settingsButtonLabel: Inställningar
|
||||||
|
rpcAuthenticationLabel: RPC-Autentisering
|
||||||
|
themeTogglerLabel: Tema-knapp
|
||||||
|
loadingLabel: Laddar...
|
||||||
|
appTitle: Apptitel
|
||||||
|
savedTemplates: Sparade mallar
|
||||||
|
templatesEditor: Mallredigerare
|
||||||
|
templatesEditorNameLabel: Namn
|
||||||
|
templatesEditorContentLabel: Innehåll
|
||||||
|
logsTitle: 'Loggar'
|
||||||
|
awaitingLogs: 'Väntar på loggar...'
|
||||||
|
bulkDownload: 'Ladda ner filer i ett zip-arkiv'
|
||||||
|
rpcPollingTimeTitle: Frekvens av RPC-uppdateringar
|
||||||
|
rpcPollingTimeDescription: En högre frekvens kräver mer CPU-resurser för både server och klient
|
||||||
|
templatesReloadInfo: För att registrera en ny mall så kan en sidomladdning krävas.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
80
frontend/src/assets/i18n/uk.yaml
Normal file
80
frontend/src/assets/i18n/uk.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
||||||
|
statusTitle: Статус
|
||||||
|
startButton: Почати
|
||||||
|
statusReady: Готово
|
||||||
|
abortAllButton: Перервати все
|
||||||
|
updateBinButton: Оновити бінарний файл yt-dlp
|
||||||
|
darkThemeButton: Темна тема
|
||||||
|
lightThemeButton: Світла тема
|
||||||
|
settingsAnchor: Налаштування
|
||||||
|
serverAddressTitle: Адреса сервера
|
||||||
|
serverPortTitle: Порт
|
||||||
|
extractAudioCheckbox: Витягти аудіо
|
||||||
|
noMTimeCheckbox: Не встановлювати час модифікації файлу
|
||||||
|
bgReminder: Як тільки ви закриєте цю сторінку, завантаження продовжиться у фоновому режимі.
|
||||||
|
toastConnected: 'Підключений до '
|
||||||
|
toastUpdated: Бінарний файл yt-dlp оновлено!
|
||||||
|
formatSelectionEnabler: Активувати вибір відео/аудіо форматів
|
||||||
|
themeSelect: 'Тема'
|
||||||
|
languageSelect: 'Мова'
|
||||||
|
overridesAnchor: Перевизначити
|
||||||
|
pathOverrideOption: Активувати перевизначення вихідного шляху
|
||||||
|
filenameOverrideOption: Активувати перевизначення імені вихідного файлу
|
||||||
|
autoFileExtensionOption: Автоматичне додавання розширення файлу
|
||||||
|
customFilename: Введіть ім'я файлу (залишіть порожнім, щоб використовувати значення за замовчуванням)
|
||||||
|
customPath: Задати шлях
|
||||||
|
customArgs: Включити аргументи, що настроюються yt-dlp (велика сила = велика відповідальність)
|
||||||
|
customArgsInput: Користувальницькі аргументи yt-dlp
|
||||||
|
rpcConnErr: Помилка при підключенні до сервера RPC
|
||||||
|
splashText: Немає активних завантажень
|
||||||
|
archiveTitle: Архів
|
||||||
|
clipboardAction: URL скопійовано в буфер обміну
|
||||||
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
newDownloadButton: New download
|
||||||
|
homeButtonLabel: Home
|
||||||
|
archiveButtonLabel: Archive
|
||||||
|
settingsButtonLabel: Settings
|
||||||
|
rpcAuthenticationLabel: RPC authentication
|
||||||
|
themeTogglerLabel: Theme toggler
|
||||||
|
loadingLabel: Loading...
|
||||||
|
appTitle: App title
|
||||||
|
savedTemplates: Saved templates
|
||||||
|
templatesEditor: Templates editor
|
||||||
|
templatesEditorNameLabel: Template name
|
||||||
|
templatesEditorContentLabel: Template content
|
||||||
|
logsTitle: 'Logs'
|
||||||
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: Livestream URL
|
||||||
|
livestreamStatusWaiting: Waiting/Wait start
|
||||||
|
livestreamStatusDownloading: Downloading
|
||||||
|
livestreamStatusCompleted: Completed
|
||||||
|
livestreamStatusErrored: Errored
|
||||||
|
livestreamStatusUnknown: Unknown
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||||
|
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||||
|
Once started the livestream will be migrated to the downloads page.
|
||||||
|
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
82
frontend/src/assets/i18n/zh_CN.yaml
Normal file
82
frontend/src/assets/i18n/zh_CN.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
keys:
|
||||||
|
urlInput: 视频 URL
|
||||||
|
statusTitle: 状态
|
||||||
|
statusReady: 就绪
|
||||||
|
selectFormatButton: 选择格式
|
||||||
|
startButton: 开始
|
||||||
|
abortAllButton: 全部中止
|
||||||
|
updateBinButton: 更新 yt-dlp 可执行文件
|
||||||
|
darkThemeButton: 黑暗主题
|
||||||
|
lightThemeButton: 明亮主题
|
||||||
|
settingsAnchor: 设置
|
||||||
|
serverAddressTitle: 服务器地址
|
||||||
|
serverPortTitle: 端口
|
||||||
|
extractAudioCheckbox: 提取音频
|
||||||
|
noMTimeCheckbox: 不设置文件修改时间
|
||||||
|
bgReminder: 关闭页面后,下载会继续在后台运行。
|
||||||
|
toastConnected: '已连接到 '
|
||||||
|
toastUpdated: 已更新 yt-dlp 可执行文件!
|
||||||
|
formatSelectionEnabler: 启用视频/音频格式选择
|
||||||
|
themeSelect: '主题'
|
||||||
|
languageSelect: '语言'
|
||||||
|
overridesAnchor: 覆盖
|
||||||
|
pathOverrideOption: 启用输出路径覆盖
|
||||||
|
filenameOverrideOption: 启用输出文件名覆盖
|
||||||
|
autoFileExtensionOption: 自动文件扩展名
|
||||||
|
customFilename: 自定义文件名(留空使用默认值)
|
||||||
|
customPath: 自定义路径
|
||||||
|
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
|
||||||
|
customArgsInput: 自定义 yt-dlp 参数
|
||||||
|
rpcConnErr: 连接 RPC 服务器发生错误
|
||||||
|
splashText: 没有正在进行的下载
|
||||||
|
archiveTitle: 归档
|
||||||
|
clipboardAction: 复制 URL 到剪贴板
|
||||||
|
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
|
||||||
|
restartAppMessage: 需要刷新页面才能生效
|
||||||
|
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
|
||||||
|
newDownloadButton: 新下载
|
||||||
|
homeButtonLabel: 主页
|
||||||
|
archiveButtonLabel: 归档
|
||||||
|
settingsButtonLabel: 设置
|
||||||
|
rpcAuthenticationLabel: RPC 身份验证
|
||||||
|
themeTogglerLabel: 主题切换
|
||||||
|
loadingLabel: 正在加载…
|
||||||
|
appTitle: App 标题
|
||||||
|
savedTemplates: 保存模板
|
||||||
|
templatesEditor: 模板编辑器
|
||||||
|
templatesEditorNameLabel: 模板名称
|
||||||
|
templatesEditorContentLabel: 模板内容
|
||||||
|
logsTitle: '日志'
|
||||||
|
awaitingLogs: '正在等待日志…'
|
||||||
|
bulkDownload: '下载 zip 压缩包中的文件'
|
||||||
|
templatesReloadInfo: To register a new template it might need a page reload.
|
||||||
|
livestreamURLInput: 直播 URL
|
||||||
|
livestreamStatusWaiting: 等待直播开始
|
||||||
|
livestreamStatusDownloading: 下载中
|
||||||
|
livestreamStatusCompleted: 已完成
|
||||||
|
livestreamStatusErrored: 发生错误
|
||||||
|
livestreamStatusUnknown: 未知
|
||||||
|
livestreamNoMonitoring: No livestreams monitored
|
||||||
|
livestreamDownloadInfo: |
|
||||||
|
本功能将会监控即将开始的直播流,每个进程都会传入参数:--wait-for-video 10 (重试间隔10秒)
|
||||||
|
如果直播已经开始,那么依然可以下载,但是不会记录下载进度。
|
||||||
|
直播开始后,将会转移到下载页面
|
||||||
|
livestreamExperimentalWarning: 实验性功能,可能存在未知Bug,请谨慎使用
|
||||||
|
accentSelect: 'Accent'
|
||||||
|
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||||
|
rpcPollingTimeTitle: RPC polling time
|
||||||
|
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
|
||||||
|
generalDownloadSettings: 'General Download Settings'
|
||||||
|
deleteCookies: Delete Cookies
|
||||||
|
noFilesFound: 'No Files Found'
|
||||||
|
tableView: 'Table View'
|
||||||
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { getOrElse } from 'fp-ts/lib/Either'
|
import { getOrElse } from 'fp-ts/lib/Either'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { atom } from 'jotai'
|
||||||
|
import { atomWithStorage } from 'jotai/utils'
|
||||||
import { ffetch } from '../lib/httpClient'
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { CustomTemplate } from '../types'
|
import { CustomTemplate } from '../types'
|
||||||
import { serverSideCookiesState, serverURL } from './settings'
|
import { serverSideCookiesState, serverURL } from './settings'
|
||||||
import { atom } from 'jotai'
|
|
||||||
import { atomWithStorage } from 'jotai/utils'
|
|
||||||
|
|
||||||
export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
|
export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
|
||||||
await get(serverSideCookiesState)
|
await get(serverSideCookiesState)
|
||||||
@@ -22,12 +22,6 @@ export const filenameTemplateState = atomWithStorage(
|
|||||||
localStorage.getItem('lastFilenameTemplate') ?? ''
|
localStorage.getItem('lastFilenameTemplate') ?? ''
|
||||||
)
|
)
|
||||||
|
|
||||||
export const downloadTemplateState = atom<Promise<string>>(async (get) =>
|
|
||||||
`${get(customArgsState)} ${await get(cookiesTemplateState)}`
|
|
||||||
.replace(/ +/g, ' ')
|
|
||||||
.trim()
|
|
||||||
)
|
|
||||||
|
|
||||||
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
|
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
|
||||||
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
||||||
const either = await task()
|
const either = await task()
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { atom } from 'jotai'
|
|
||||||
import I18nBuilder from '../lib/intl'
|
|
||||||
import { languageState } from './settings'
|
|
||||||
|
|
||||||
export const i18nBuilderState = atom((get) => new I18nBuilder(get(languageState)))
|
|
||||||
@@ -6,19 +6,21 @@ import { atomWithStorage } from 'jotai/utils'
|
|||||||
import { atom } from 'jotai'
|
import { atom } from 'jotai'
|
||||||
|
|
||||||
export const languages = [
|
export const languages = [
|
||||||
'english',
|
|
||||||
'chinese',
|
|
||||||
'russian',
|
|
||||||
'french',
|
|
||||||
'italian',
|
|
||||||
'spanish',
|
|
||||||
'korean',
|
|
||||||
'japanese',
|
|
||||||
'catalan',
|
'catalan',
|
||||||
'ukrainian',
|
'chinese',
|
||||||
'swedish',
|
'english',
|
||||||
|
'french',
|
||||||
|
'german',
|
||||||
|
'italian',
|
||||||
|
'japanese',
|
||||||
|
'korean',
|
||||||
'polish',
|
'polish',
|
||||||
'german'
|
'portuguese-br',
|
||||||
|
'russian',
|
||||||
|
'spanish',
|
||||||
|
'swedish',
|
||||||
|
'ukrainian',
|
||||||
|
'hungarian'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type Language = (typeof languages)[number]
|
export type Language = (typeof languages)[number]
|
||||||
@@ -26,14 +28,19 @@ export type Language = (typeof languages)[number]
|
|||||||
export type Theme = 'light' | 'dark' | 'system'
|
export type Theme = 'light' | 'dark' | 'system'
|
||||||
export type ThemeNarrowed = 'light' | 'dark'
|
export type ThemeNarrowed = 'light' | 'dark'
|
||||||
|
|
||||||
|
export const accents = ['default', 'red'] as const
|
||||||
|
export type Accent = (typeof accents)[number]
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
serverAddr: string
|
serverAddr: string
|
||||||
serverPort: number
|
serverPort: number
|
||||||
language: Language
|
language: Language
|
||||||
theme: ThemeNarrowed
|
theme: ThemeNarrowed
|
||||||
|
accent: Accent
|
||||||
cliArgs: string
|
cliArgs: string
|
||||||
formatSelection: boolean
|
formatSelection: boolean
|
||||||
fileRenaming: boolean
|
fileRenaming: boolean
|
||||||
|
autoFileExtension: boolean
|
||||||
pathOverriding: boolean
|
pathOverriding: boolean
|
||||||
enableCustomArgs: boolean
|
enableCustomArgs: boolean
|
||||||
listView: boolean
|
listView: boolean
|
||||||
@@ -76,6 +83,11 @@ export const fileRenamingState = atomWithStorage(
|
|||||||
localStorage.getItem('file-renaming') === 'true'
|
localStorage.getItem('file-renaming') === 'true'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const autoFileExtensionState = atomWithStorage(
|
||||||
|
'auto-file-extension',
|
||||||
|
localStorage.getItem('auto-file-extension') === 'true'
|
||||||
|
)
|
||||||
|
|
||||||
export const pathOverridingState = atomWithStorage(
|
export const pathOverridingState = atomWithStorage(
|
||||||
'path-overriding',
|
'path-overriding',
|
||||||
localStorage.getItem('path-overriding') === 'true'
|
localStorage.getItem('path-overriding') === 'true'
|
||||||
@@ -109,11 +121,18 @@ export const appTitleState = atomWithStorage(
|
|||||||
export const serverAddressAndPortState = atom((get) => {
|
export const serverAddressAndPortState = atom((get) => {
|
||||||
if (get(servedFromReverseProxySubDirState)) {
|
if (get(servedFromReverseProxySubDirState)) {
|
||||||
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
|
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
|
||||||
|
.replaceAll('"', '') // XXX: atomWithStorage uses JSON.stringify to serialize
|
||||||
|
.replaceAll('//', '/') // which puts extra double quotes.
|
||||||
}
|
}
|
||||||
if (get(servedFromReverseProxyState)) {
|
if (get(servedFromReverseProxyState)) {
|
||||||
return `${get(serverAddressState)}`
|
return `${get(serverAddressState)}`
|
||||||
|
.replaceAll('"', '')
|
||||||
}
|
}
|
||||||
return `${get(serverAddressState)}:${get(serverPortState)}`
|
|
||||||
|
const sap = `${get(serverAddressState)}:${get(serverPortState)}`
|
||||||
|
.replaceAll('"', '')
|
||||||
|
|
||||||
|
return sap.endsWith('/') ? sap.slice(0, -1) : sap
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serverURL = atom((get) =>
|
export const serverURL = atom((get) =>
|
||||||
@@ -122,15 +141,17 @@ export const serverURL = atom((get) =>
|
|||||||
|
|
||||||
export const rpcWebSocketEndpoint = atom((get) => {
|
export const rpcWebSocketEndpoint = atom((get) => {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
|
const sap = get(serverAddressAndPortState)
|
||||||
}
|
|
||||||
)
|
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/ws`
|
||||||
|
})
|
||||||
|
|
||||||
export const rpcHTTPEndpoint = atom((get) => {
|
export const rpcHTTPEndpoint = atom((get) => {
|
||||||
const proto = window.location.protocol
|
const proto = window.location.protocol
|
||||||
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
|
const sap = get(serverAddressAndPortState)
|
||||||
}
|
|
||||||
)
|
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/http`
|
||||||
|
})
|
||||||
|
|
||||||
export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe(
|
export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe(
|
||||||
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
|
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
|
||||||
@@ -146,7 +167,11 @@ const themeSelector = atom<ThemeNarrowed>((get) => {
|
|||||||
return 'dark'
|
return 'dark'
|
||||||
}
|
}
|
||||||
return 'light'
|
return 'light'
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export const accentState = atomWithStorage<Accent>(
|
||||||
|
'accent-color',
|
||||||
|
localStorage.getItem('accent-color') as Accent ?? 'default',
|
||||||
)
|
)
|
||||||
|
|
||||||
export const settingsState = atom<SettingsState>((get) => ({
|
export const settingsState = atom<SettingsState>((get) => ({
|
||||||
@@ -154,13 +179,14 @@ export const settingsState = atom<SettingsState>((get) => ({
|
|||||||
serverPort: get(serverPortState),
|
serverPort: get(serverPortState),
|
||||||
language: get(languageState),
|
language: get(languageState),
|
||||||
theme: get(themeSelector),
|
theme: get(themeSelector),
|
||||||
|
accent: get(accentState),
|
||||||
cliArgs: get(latestCliArgumentsState),
|
cliArgs: get(latestCliArgumentsState),
|
||||||
formatSelection: get(formatSelectionState),
|
formatSelection: get(formatSelectionState),
|
||||||
fileRenaming: get(fileRenamingState),
|
fileRenaming: get(fileRenamingState),
|
||||||
|
autoFileExtension: get(autoFileExtensionState),
|
||||||
pathOverriding: get(pathOverridingState),
|
pathOverriding: get(pathOverridingState),
|
||||||
enableCustomArgs: get(enableCustomArgsState),
|
enableCustomArgs: get(enableCustomArgsState),
|
||||||
listView: get(listViewState),
|
listView: get(listViewState),
|
||||||
servedFromReverseProxy: get(servedFromReverseProxyState),
|
servedFromReverseProxy: get(servedFromReverseProxyState),
|
||||||
appTitle: get(appTitleState)
|
appTitle: get(appTitleState)
|
||||||
})
|
}))
|
||||||
)
|
|
||||||
|
|||||||
98
frontend/src/components/ArchiveCard.tsx
Normal file
98
frontend/src/components/ArchiveCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
|
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'
|
||||||
|
import SaveAltIcon from '@mui/icons-material/SaveAlt'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
IconButton,
|
||||||
|
Skeleton,
|
||||||
|
Tooltip,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { ArchiveEntry } from '../types'
|
||||||
|
import { base64URLEncode, ellipsis } from '../utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entry: ArchiveEntry
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onHardDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArchiveCard: React.FC<Props> = ({ entry, onDelete, onHardDelete }) => {
|
||||||
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const viewFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardActionArea onClick={() => navigator.clipboard.writeText(entry.source)}>
|
||||||
|
{entry.thumbnail !== '' ?
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height={180}
|
||||||
|
image={entry.thumbnail}
|
||||||
|
/> :
|
||||||
|
<Skeleton variant="rectangular" height={180} />
|
||||||
|
}
|
||||||
|
<CardContent>
|
||||||
|
{entry.title !== '' ?
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{ellipsis(entry.title, 60)}
|
||||||
|
</Typography> :
|
||||||
|
<Skeleton />
|
||||||
|
}
|
||||||
|
{/* <code>
|
||||||
|
{JSON.stringify(JSON.parse(entry.metadata), null, 2)}
|
||||||
|
</code> */}
|
||||||
|
<p>{new Date(entry.created_at).toLocaleString()}</p>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
<CardActions>
|
||||||
|
<Tooltip title="Open in browser">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => viewFile(entry.path)}
|
||||||
|
>
|
||||||
|
<OpenInBrowserIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Download this file">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => downloadFile(entry.path)}
|
||||||
|
>
|
||||||
|
<SaveAltIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete from archive">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onDelete(entry.id)}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete from disk">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onHardDelete(entry.id)}
|
||||||
|
>
|
||||||
|
<DeleteForeverIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArchiveCard
|
||||||
@@ -11,6 +11,9 @@ import { useSubscription } from '../hooks/observable'
|
|||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
import { ffetch } from '../lib/httpClient'
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
const validateCookie = (cookie: string) => pipe(
|
const validateCookie = (cookie: string) => pipe(
|
||||||
cookie,
|
cookie,
|
||||||
@@ -164,7 +167,7 @@ const CookiesTextField: React.FC = () => {
|
|||||||
defaultValue={savedCookies}
|
defaultValue={savedCookies}
|
||||||
onChange={(e) => cookies$.next(e.currentTarget.value)}
|
onChange={(e) => cookies$.next(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Button onClick={deleteCookies}>Delete cookies</Button>
|
<Button onClick={deleteCookies}>{i18n.t('deleteCookies')}</Button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
35
frontend/src/components/CustomArgsTextField.tsx
Normal file
35
frontend/src/components/CustomArgsTextField.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { TextField } from '@mui/material'
|
||||||
|
import { useAtom, useAtomValue } from 'jotai'
|
||||||
|
import { customArgsState } from '../atoms/downloadTemplate'
|
||||||
|
import { settingsState } from '../atoms/settings'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
const CustomArgsTextField: React.FC = () => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
const settings = useAtomValue(settingsState)
|
||||||
|
|
||||||
|
const [customArgs, setCustomArgs] = useAtom(customArgsState)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomArgs('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setCustomArgs(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('customArgsInput')}
|
||||||
|
variant="outlined"
|
||||||
|
onChange={handleCustomArgsChange}
|
||||||
|
value={customArgs}
|
||||||
|
disabled={settings.formatSelection}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomArgsTextField
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import EightK from '@mui/icons-material/EightK'
|
|
||||||
import FourK from '@mui/icons-material/FourK'
|
|
||||||
import Hd from '@mui/icons-material/Hd'
|
|
||||||
import Sd from '@mui/icons-material/Sd'
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -10,16 +6,23 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardMedia,
|
CardMedia,
|
||||||
Chip,
|
Chip,
|
||||||
|
IconButton,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tooltip,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { serverURL } from '../atoms/settings'
|
import { serverURL } from '../atoms/settings'
|
||||||
import { RPCResult } from '../types'
|
import { RPCResult } from '../types'
|
||||||
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
|
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
|
||||||
import { useAtomValue } from 'jotai'
|
import ResolutionBadge from './ResolutionBadge'
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear'
|
||||||
|
import StopCircleIcon from '@mui/icons-material/StopCircle'
|
||||||
|
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'
|
||||||
|
import SaveAltIcon from '@mui/icons-material/SaveAlt'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
download: RPCResult
|
download: RPCResult
|
||||||
@@ -27,15 +30,6 @@ type Props = {
|
|||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
|
|
||||||
if (!resolution) return null
|
|
||||||
if (resolution.includes('4320')) return <EightK color="primary" />
|
|
||||||
if (resolution.includes('2160')) return <FourK color="primary" />
|
|
||||||
if (resolution.includes('1080')) return <Hd color="primary" />
|
|
||||||
if (resolution.includes('720')) return <Sd color="primary" />
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
||||||
const serverAddr = useAtomValue(serverURL)
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
|
||||||
@@ -53,12 +47,12 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
|
|
||||||
const viewFile = (path: string) => {
|
const viewFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadFile = (path: string) => {
|
const downloadFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,37 +104,44 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
<Typography>
|
<Typography>
|
||||||
{formatSize(download.info.filesize_approx ?? 0)}
|
{formatSize(download.info.filesize_approx ?? 0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Resolution resolution={download.info.resolution} />
|
<ResolutionBadge resolution={download.info.resolution} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
<CardActions>
|
<CardActions>
|
||||||
<Button
|
{isCompleted() ?
|
||||||
variant="contained"
|
<Tooltip title="Clear from the view">
|
||||||
size="small"
|
<IconButton
|
||||||
color="primary"
|
onClick={onStop}
|
||||||
onClick={onStop}
|
>
|
||||||
>
|
<ClearIcon />
|
||||||
{isCompleted() ? "Clear" : "Stop"}
|
</IconButton>
|
||||||
</Button>
|
</Tooltip>
|
||||||
|
:
|
||||||
|
<Tooltip title="Stop this download">
|
||||||
|
<IconButton
|
||||||
|
onClick={onStop}
|
||||||
|
>
|
||||||
|
<StopCircleIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
{isCompleted() &&
|
{isCompleted() &&
|
||||||
<>
|
<>
|
||||||
<Button
|
<Tooltip title="Download this file">
|
||||||
variant="contained"
|
<IconButton
|
||||||
size="small"
|
onClick={() => downloadFile(download.output.savedFilePath)}
|
||||||
color="primary"
|
>
|
||||||
onClick={() => downloadFile(download.output.savedFilePath)}
|
<SaveAltIcon />
|
||||||
>
|
</IconButton>
|
||||||
Download
|
</Tooltip>
|
||||||
</Button>
|
<Tooltip title="Open in a new tab">
|
||||||
<Button
|
<IconButton
|
||||||
variant="contained"
|
onClick={() => viewFile(download.output.savedFilePath)}
|
||||||
size="small"
|
>
|
||||||
color="primary"
|
<OpenInBrowserIcon />
|
||||||
onClick={() => viewFile(download.output.savedFilePath)}
|
</IconButton>
|
||||||
>
|
</Tooltip>
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
|
Select,
|
||||||
|
SelectChangeEvent,
|
||||||
TextField
|
TextField
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import AppBar from '@mui/material/AppBar'
|
import AppBar from '@mui/material/AppBar'
|
||||||
@@ -25,14 +28,13 @@ import {
|
|||||||
FC,
|
FC,
|
||||||
Suspense,
|
Suspense,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
|
cookiesTemplateState,
|
||||||
customArgsState,
|
customArgsState,
|
||||||
downloadTemplateState,
|
|
||||||
filenameTemplateState,
|
filenameTemplateState,
|
||||||
savedTemplatesState
|
savedTemplatesState
|
||||||
} from '../atoms/downloadTemplate'
|
} from '../atoms/downloadTemplate'
|
||||||
@@ -44,6 +46,7 @@ import { useI18n } from '../hooks/useI18n'
|
|||||||
import { useRPC } from '../hooks/useRPC'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import type { DLMetadata } from '../types'
|
import type { DLMetadata } from '../types'
|
||||||
import { toFormatArgs } from '../utils'
|
import { toFormatArgs } from '../utils'
|
||||||
|
import CustomArgsTextField from './CustomArgsTextField'
|
||||||
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
||||||
import LoadingBackdrop from './LoadingBackdrop'
|
import LoadingBackdrop from './LoadingBackdrop'
|
||||||
|
|
||||||
@@ -66,8 +69,9 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
const settings = useAtomValue(settingsState)
|
const settings = useAtomValue(settingsState)
|
||||||
const isConnected = useAtomValue(connectedState)
|
const isConnected = useAtomValue(connectedState)
|
||||||
const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
|
const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
|
||||||
const downloadTemplate = useAtomValue(downloadTemplateState)
|
|
||||||
const savedTemplates = useAtomValue(savedTemplatesState)
|
const savedTemplates = useAtomValue(savedTemplatesState)
|
||||||
|
const customArgs = useAtomValue(customArgsState)
|
||||||
|
const cookies = useAtomValue(cookiesTemplateState)
|
||||||
|
|
||||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||||
@@ -75,14 +79,14 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||||
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
|
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
|
||||||
|
|
||||||
const [customArgs, setCustomArgs] = useAtom(customArgsState)
|
|
||||||
|
|
||||||
const [downloadPath, setDownloadPath] = useState('')
|
const [downloadPath, setDownloadPath] = useState('')
|
||||||
|
|
||||||
const [filenameTemplate, setFilenameTemplate] = useAtom(
|
const [filenameTemplate, setFilenameTemplate] = useAtom(
|
||||||
filenameTemplateState
|
filenameTemplateState
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [fileExtension, setFileExtension] = useState('.%(ext)s')
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
|
|
||||||
const [isPlaylist, setIsPlaylist] = useState(false)
|
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||||
@@ -96,10 +100,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCustomArgs('')
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||||
*/
|
*/
|
||||||
@@ -110,12 +110,16 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
||||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
||||||
|
|
||||||
|
const downloadTemplate = `${customArgs} ${cookies}`
|
||||||
|
.replace(/ +/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 10))
|
await new Promise(r => setTimeout(r, 10))
|
||||||
client.download({
|
client.download({
|
||||||
url: immediate || line,
|
url: immediate || line,
|
||||||
args: `${toFormatArgs(codes)} ${downloadTemplate}`,
|
args: `${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||||
pathOverride: downloadPath ?? '',
|
pathOverride: downloadPath ?? '',
|
||||||
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
renameTo: settings.fileRenaming ? filenameTemplate + (settings.autoFileExtension ? fileExtension : '') : '',
|
||||||
playlist: isPlaylist,
|
playlist: isPlaylist,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -169,8 +173,8 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
setFilenameTemplate(e.target.value)
|
setFilenameTemplate(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileExtensionChange = (e: SelectChangeEvent<string>) => {
|
||||||
setCustomArgs(e.target.value)
|
setFileExtension(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -268,25 +272,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||||
{
|
{settings.enableCustomArgs &&
|
||||||
settings.enableCustomArgs &&
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<CustomArgsTextField />
|
||||||
fullWidth
|
|
||||||
label={i18n.t('customArgsInput')}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={handleCustomArgsChange}
|
|
||||||
value={customArgs}
|
|
||||||
disabled={
|
|
||||||
!isConnected ||
|
|
||||||
(settings.formatSelection && downloadFormats != null)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
settings.fileRenaming &&
|
settings.fileRenaming &&
|
||||||
<Grid item xs={settings.pathOverriding ? 8 : 12}>
|
<Grid item xs={
|
||||||
|
!settings.autoFileExtension && !settings.pathOverriding
|
||||||
|
? 12
|
||||||
|
: !settings.autoFileExtension && settings.pathOverriding
|
||||||
|
? 8
|
||||||
|
: settings.autoFileExtension && !settings.pathOverriding
|
||||||
|
? 10
|
||||||
|
: 6
|
||||||
|
}>
|
||||||
<TextField
|
<TextField
|
||||||
sx={{ mt: 1 }}
|
sx={{ mt: 1 }}
|
||||||
ref={customFilenameInputRef}
|
ref={customFilenameInputRef}
|
||||||
@@ -302,6 +303,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
settings.autoFileExtension &&
|
||||||
|
<Grid item xs={2}>
|
||||||
|
<Select
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('autoFileExtension')}
|
||||||
|
value={fileExtension}
|
||||||
|
onChange={handleFileExtensionChange}
|
||||||
|
variant="outlined">
|
||||||
|
<MenuItem value=".%(ext)s">Auto</MenuItem>
|
||||||
|
<MenuItem value=".mp4">mp4</MenuItem>
|
||||||
|
<MenuItem value=".mkv">mkv</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
{
|
{
|
||||||
settings.pathOverriding &&
|
settings.pathOverriding &&
|
||||||
<Grid item xs={4}>
|
<Grid item xs={4}>
|
||||||
@@ -348,7 +365,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
disabled={url === ''}
|
disabled={url === ''}
|
||||||
onClick={() => settings.formatSelection
|
onClick={() => settings.formatSelection
|
||||||
? startTransition(() => sendUrlFormatSelection())
|
? startTransition(() => sendUrlFormatSelection())
|
||||||
: sendUrl()
|
: startTransition(async () => await sendUrl())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Grid } from '@mui/material'
|
import { Grid2 } from '@mui/material'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useTransition } from 'react'
|
||||||
import { activeDownloadsState } from '../atoms/downloads'
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { useRPC } from '../hooks/useRPC'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import { ProcessStatus, RPCResult } from '../types'
|
import { ProcessStatus, RPCResult } from '../types'
|
||||||
import DownloadCard from './DownloadCard'
|
import DownloadCard from './DownloadCard'
|
||||||
|
import LoadingBackdrop from './LoadingBackdrop'
|
||||||
|
|
||||||
const DownloadsGridView: React.FC = () => {
|
const DownloadsGridView: React.FC = () => {
|
||||||
const downloads = useAtomValue(activeDownloadsState)
|
const downloads = useAtomValue(activeDownloadsState)
|
||||||
@@ -14,24 +16,31 @@ const DownloadsGridView: React.FC = () => {
|
|||||||
const { client } = useRPC()
|
const { client } = useRPC()
|
||||||
const { pushMessage } = useToast()
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
const [isPending, startTransition] = useTransition()
|
||||||
? client.clear(r.id)
|
|
||||||
: client.kill(r.id)
|
const stop = async (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
||||||
|
? await client.clear(r.id)
|
||||||
|
: await client.kill(r.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
|
<>
|
||||||
{
|
<LoadingBackdrop isLoading={isPending} />
|
||||||
downloads.map(download => (
|
<Grid2 container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
|
||||||
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
|
{
|
||||||
<DownloadCard
|
downloads.map(download => (
|
||||||
download={download}
|
<Grid2 size={{ xs: 4, sm: 8, md: 6, xl: 4 }} key={download.id}>
|
||||||
onStop={() => stop(download)}
|
<DownloadCard
|
||||||
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
download={download}
|
||||||
/>
|
onStop={() => startTransition(async () => {
|
||||||
</Grid>
|
await stop(download)
|
||||||
))
|
})}
|
||||||
}
|
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
||||||
</Grid>
|
/>
|
||||||
|
</Grid2>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Grid2>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,12 +125,12 @@ const DownloadsTableView: React.FC = () => {
|
|||||||
|
|
||||||
const viewFile = (path: string) => {
|
const viewFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadFile = (path: string) => {
|
const downloadFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
||||||
|
|||||||
45
frontend/src/components/EmptyArchive.tsx
Normal file
45
frontend/src/components/EmptyArchive.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import ArchiveIcon from '@mui/icons-material/Archive'
|
||||||
|
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||||
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
const FlexContainer = styled(Container)({
|
||||||
|
display: 'flex',
|
||||||
|
minWidth: '100%',
|
||||||
|
minHeight: '80vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column'
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = styled(Typography)({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingBottom: '0.5rem'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function EmptyArchive() {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const activeDownloads = useAtomValue(activeDownloadsState)
|
||||||
|
|
||||||
|
if (activeDownloads.length !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlexContainer>
|
||||||
|
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||||
|
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||||
|
<ArchiveIcon />
|
||||||
|
</SvgIcon>
|
||||||
|
</Title>
|
||||||
|
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||||
|
{/* {i18n.t('splashText')} */}
|
||||||
|
Empty Archive
|
||||||
|
</Title>
|
||||||
|
</FlexContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
|
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
|
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { useAtom, useAtomValue } from 'jotai'
|
|
||||||
|
|
||||||
const ExtraDownloadOptions: React.FC = () => {
|
const ExtraDownloadOptions: React.FC = () => {
|
||||||
const { i18n } = useI18n()
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
const customTemplates = useAtomValue(savedTemplatesState)
|
const customTemplates = useAtomValue(savedTemplatesState)
|
||||||
const [, setCustomArgs] = useAtom(customArgsState)
|
const setCustomArgs = useSetAtom(customArgsState)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomArgs(
|
||||||
|
customTemplates
|
||||||
|
.find(f => f.name.toLocaleLowerCase() === 'default')
|
||||||
|
?.content ?? ''
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -17,7 +26,7 @@ const ExtraDownloadOptions: React.FC = () => {
|
|||||||
autoHighlight
|
autoHighlight
|
||||||
defaultValue={
|
defaultValue={
|
||||||
customTemplates
|
customTemplates
|
||||||
.filter(({ id, name }) => id === "0" || name === "default")
|
.filter(({ id, name }) => id === "0" || name.toLowerCase() === "default")
|
||||||
.map(({ name, content }) => ({ label: name, content }))
|
.map(({ name, content }) => ({ label: name, content }))
|
||||||
.at(0)
|
.at(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
|
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
|
||||||
|
import ClearAllIcon from '@mui/icons-material/ClearAll'
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
import FolderZipIcon from '@mui/icons-material/FolderZip'
|
import FolderZipIcon from '@mui/icons-material/FolderZip'
|
||||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||||
@@ -34,7 +35,7 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
|||||||
>
|
>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
|
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
|
||||||
tooltipTitle={listView ? 'Card view' : 'Table view'}
|
tooltipTitle={listView ? 'Card view' : i18n.t('tableView')}
|
||||||
onClick={() => setListView(state => !state)}
|
onClick={() => setListView(state => !state)}
|
||||||
/>
|
/>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
@@ -42,6 +43,11 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
|||||||
tooltipTitle={i18n.t('bulkDownload')}
|
tooltipTitle={i18n.t('bulkDownload')}
|
||||||
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
|
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
|
||||||
/>
|
/>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<ClearAllIcon />}
|
||||||
|
tooltipTitle={i18n.t('clearCompletedButton')}
|
||||||
|
onClick={() => client.clearCompleted()}
|
||||||
|
/>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
icon={<DeleteForeverIcon />}
|
icon={<DeleteForeverIcon />}
|
||||||
tooltipTitle={i18n.t('abortAllButton')}
|
tooltipTitle={i18n.t('abortAllButton')}
|
||||||
|
|||||||
15
frontend/src/components/ResolutionBadge.tsx
Normal file
15
frontend/src/components/ResolutionBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import EightK from '@mui/icons-material/EightK'
|
||||||
|
import FourK from '@mui/icons-material/FourK'
|
||||||
|
import Hd from '@mui/icons-material/Hd'
|
||||||
|
import Sd from '@mui/icons-material/Sd'
|
||||||
|
|
||||||
|
const ResolutionBadge: React.FC<{ resolution?: string }> = ({ resolution }) => {
|
||||||
|
if (!resolution) return null
|
||||||
|
if (resolution.includes('4320')) return <EightK color="primary" />
|
||||||
|
if (resolution.includes('2160')) return <FourK color="primary" />
|
||||||
|
if (resolution.includes('1080')) return <Hd color="primary" />
|
||||||
|
if (resolution.includes('720')) return <Sd color="primary" />
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResolutionBadge
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import CloseIcon from '@mui/icons-material/Close'
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AppBar,
|
AppBar,
|
||||||
@@ -20,13 +18,13 @@ import {
|
|||||||
import { TransitionProps } from '@mui/material/transitions'
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
import { matchW } from 'fp-ts/lib/Either'
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
||||||
import { serverURL } from '../atoms/settings'
|
import { serverURL } from '../atoms/settings'
|
||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { ffetch } from '../lib/httpClient'
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { CustomTemplate } from '../types'
|
import { CustomTemplate } from '../types'
|
||||||
import { useAtomValue } from 'jotai'
|
|
||||||
import TemplateTextField from './TemplateTextField'
|
import TemplateTextField from './TemplateTextField'
|
||||||
|
|
||||||
const Transition = forwardRef(function Transition(
|
const Transition = forwardRef(function Transition(
|
||||||
@@ -201,7 +199,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: <Button
|
endAdornment: <Button
|
||||||
variant='contained'
|
variant='contained'
|
||||||
onClick={() => startTransition(() => { addTemplate() })}
|
onClick={() => startTransition(async () => await addTemplate())}
|
||||||
>
|
>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
22
frontend/src/components/TwitchIcon.tsx
Normal file
22
frontend/src/components/TwitchIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { settingsState } from '../atoms/settings'
|
||||||
|
|
||||||
|
const TwitchIcon: React.FC = () => {
|
||||||
|
const { theme } = useAtomValue(settingsState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ fill: theme === 'dark' ? '#fff' : '#757575' }}
|
||||||
|
>
|
||||||
|
<title>Twitch</title>
|
||||||
|
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchIcon
|
||||||
@@ -7,6 +7,7 @@ const VersionIndicator: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<Chip label={`UI v3.2.5`} variant="outlined" size="small" />
|
||||||
<Chip label={`RPC v${version.rpcVersion}`} variant="outlined" size="small" />
|
<Chip label={`RPC v${version.rpcVersion}`} variant="outlined" size="small" />
|
||||||
<Chip label={`yt-dlp v${version.ytdlpVersion}`} variant="outlined" size="small" />
|
<Chip label={`yt-dlp v${version.ytdlpVersion}`} variant="outlined" size="small" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function NoLivestreams() {
|
|||||||
</SvgIcon>
|
</SvgIcon>
|
||||||
</Title>
|
</Title>
|
||||||
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||||
No livestreams monitored
|
{i18n.t('livestreamNoMonitoring')}
|
||||||
</Title>
|
</Title>
|
||||||
</FlexContainer>
|
</FlexContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
38
frontend/src/components/subscriptions/NoSubscriptions.tsx
Normal file
38
frontend/src/components/subscriptions/NoSubscriptions.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import UpdateIcon from '@mui/icons-material/Update'
|
||||||
|
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
|
||||||
|
const FlexContainer = styled(Container)({
|
||||||
|
display: 'flex',
|
||||||
|
minWidth: '100%',
|
||||||
|
minHeight: '80vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column'
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = styled(Typography)({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingBottom: '0.5rem'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export default function NoSubscriptions() {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlexContainer>
|
||||||
|
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||||
|
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||||
|
<UpdateIcon />
|
||||||
|
</SvgIcon>
|
||||||
|
</Title>
|
||||||
|
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||||
|
{i18n.t('subscriptionsEmptyLabel')}
|
||||||
|
</Title>
|
||||||
|
</FlexContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
frontend/src/components/subscriptions/SubscriptionsDialog.tsx
Normal file
164
frontend/src/components/subscriptions/SubscriptionsDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Slide,
|
||||||
|
TextField,
|
||||||
|
Toolbar,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { forwardRef, startTransition, useState } from 'react'
|
||||||
|
import { customArgsState } from '../../atoms/downloadTemplate'
|
||||||
|
import { serverURL } from '../../atoms/settings'
|
||||||
|
import { useToast } from '../../hooks/toast'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
import { ffetch } from '../../lib/httpClient'
|
||||||
|
import { Subscription } from '../../services/subscriptions'
|
||||||
|
import ExtraDownloadOptions from '../ExtraDownloadOptions'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Transition = forwardRef(function Transition(
|
||||||
|
props: TransitionProps & {
|
||||||
|
children: React.ReactElement
|
||||||
|
},
|
||||||
|
ref: React.Ref<unknown>,
|
||||||
|
) {
|
||||||
|
return <Slide direction="up" ref={ref} {...props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
const SubscriptionsDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [subscriptionURL, setSubscriptionURL] = useState('')
|
||||||
|
const [subscriptionCron, setSubscriptionCron] = useState('')
|
||||||
|
|
||||||
|
const customArgs = useAtomValue(customArgsState)
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const submit = async (sub: Omit<Subscription, 'id'>) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(sub)
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => onClose()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullScreen
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
TransitionComponent={Transition}
|
||||||
|
>
|
||||||
|
<AppBar sx={{ position: 'relative' }}>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||||
|
{i18n.t('subscriptionsButtonLabel')}
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box sx={{
|
||||||
|
backgroundColor: (theme) => theme.palette.background.default,
|
||||||
|
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||||
|
}}>
|
||||||
|
<Container sx={{ my: 4 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container gap={1.5}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity="info">
|
||||||
|
{i18n.t('subscriptionsInfo')}
|
||||||
|
</Alert>
|
||||||
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||||
|
{i18n.t('livestreamExperimentalWarning')}
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} mt={1}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('subscriptionsURLInput')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="https://www.youtube.com/@SomeChannelThatExists/videos"
|
||||||
|
onChange={(e) => setSubscriptionURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8} mt={-2}>
|
||||||
|
<ExtraDownloadOptions />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={3.871}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('cronExpressionLabel')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="*/5 * * * *"
|
||||||
|
onChange={(e) => setSubscriptionCron(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
variant="contained"
|
||||||
|
disabled={subscriptionURL === ''}
|
||||||
|
onClick={() => startTransition(() => submit({
|
||||||
|
url: subscriptionURL,
|
||||||
|
params: customArgs,
|
||||||
|
cron_expression: subscriptionCron
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{i18n.t('startButton')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsDialog
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Slide,
|
||||||
|
TextField,
|
||||||
|
Toolbar,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { forwardRef, startTransition, useState } from 'react'
|
||||||
|
import { customArgsState } from '../../atoms/downloadTemplate'
|
||||||
|
import { serverURL } from '../../atoms/settings'
|
||||||
|
import { useToast } from '../../hooks/toast'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
import { ffetch } from '../../lib/httpClient'
|
||||||
|
import { Subscription } from '../../services/subscriptions'
|
||||||
|
import ExtraDownloadOptions from '../ExtraDownloadOptions'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
subscription: Subscription | undefined
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Transition = forwardRef(function Transition(
|
||||||
|
props: TransitionProps & {
|
||||||
|
children: React.ReactElement
|
||||||
|
},
|
||||||
|
ref: React.Ref<unknown>,
|
||||||
|
) {
|
||||||
|
return <Slide direction="up" ref={ref} {...props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
const SubscriptionsEditDialog: React.FC<Props> = ({ subscription, onClose }) => {
|
||||||
|
const [subscriptionURL, setSubscriptionURL] = useState('')
|
||||||
|
const [subscriptionCron, setSubscriptionCron] = useState('')
|
||||||
|
|
||||||
|
const customArgs = useAtomValue(customArgsState)
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const editSubscription = async (sub: Subscription) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(sub)
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => onClose()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullScreen
|
||||||
|
open={!!subscription}
|
||||||
|
TransitionComponent={Transition}
|
||||||
|
>
|
||||||
|
<AppBar sx={{ position: 'relative' }}>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => onClose()}
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||||
|
{i18n.t('subscriptionsButtonLabel')}
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box sx={{
|
||||||
|
backgroundColor: (theme) => theme.palette.background.default,
|
||||||
|
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||||
|
}}>
|
||||||
|
<Container sx={{ my: 4 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container gap={1.5}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity="info">
|
||||||
|
Editing {subscription?.url}
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} mt={1}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('subscriptionsURLInput')}
|
||||||
|
variant="outlined"
|
||||||
|
defaultValue={subscription?.url}
|
||||||
|
placeholder="https://www.youtube.com/@SomeChannelThatExists/videos"
|
||||||
|
onChange={(e) => setSubscriptionURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8} mt={-2}>
|
||||||
|
<ExtraDownloadOptions />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={3.871}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('cronExpressionLabel')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="*/5 * * * *"
|
||||||
|
defaultValue={subscription?.cron_expression}
|
||||||
|
onChange={(e) => setSubscriptionCron(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => startTransition(async () => await editSubscription({
|
||||||
|
id: subscription?.id ?? '',
|
||||||
|
url: subscriptionURL || subscription?.url!,
|
||||||
|
params: customArgs || subscription?.params!,
|
||||||
|
cron_expression: subscriptionCron || subscription?.cron_expression!
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{i18n.t('editButtonLabel')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsEditDialog
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||||
|
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubscriptionsSpeedDial: React.FC<Props> = ({ onOpen }) => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel="Subscriptions speed dial"
|
||||||
|
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||||
|
icon={<SpeedDialIcon />}
|
||||||
|
>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<AddCircleIcon />}
|
||||||
|
tooltipTitle={i18n.t('newSubscriptionButton')}
|
||||||
|
onClick={onOpen}
|
||||||
|
/>
|
||||||
|
</SpeedDial>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsSpeedDial
|
||||||
140
frontend/src/components/twitch/TwitchDialog.tsx
Normal file
140
frontend/src/components/twitch/TwitchDialog.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Slide,
|
||||||
|
TextField,
|
||||||
|
Toolbar,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { forwardRef, startTransition, useState } from 'react'
|
||||||
|
import { serverURL } from '../../atoms/settings'
|
||||||
|
import { useToast } from '../../hooks/toast'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
import { ffetch } from '../../lib/httpClient'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Transition = forwardRef(function Transition(
|
||||||
|
props: TransitionProps & {
|
||||||
|
children: React.ReactElement
|
||||||
|
},
|
||||||
|
ref: React.Ref<unknown>,
|
||||||
|
) {
|
||||||
|
return <Slide direction="up" ref={ref} {...props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
const TwitchDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [channelURL, setChannelURL] = useState('')
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const submit = async (channelURL: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/twitch/user`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: channelURL.split('/').at(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => onClose()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullScreen
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
TransitionComponent={Transition}
|
||||||
|
>
|
||||||
|
<AppBar sx={{ position: 'relative' }}>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||||
|
{i18n.t('subscriptionsButtonLabel')}
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box sx={{
|
||||||
|
backgroundColor: (theme) => theme.palette.background.default,
|
||||||
|
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||||
|
}}>
|
||||||
|
<Container sx={{ my: 4 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container gap={1.5}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity="info">
|
||||||
|
{i18n.t('twitchIntegrationInfo')}
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} mt={1}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('subscriptionsURLInput')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="https://www.twitch.tv/a_twitch_user_that_exists"
|
||||||
|
onChange={(e) => setChannelURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
variant="contained"
|
||||||
|
disabled={channelURL === ''}
|
||||||
|
onClick={() => startTransition(() => submit(channelURL))}
|
||||||
|
>
|
||||||
|
{i18n.t('startButton')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchDialog
|
||||||
35
frontend/src/hooks/useFetch.ts
Normal file
35
frontend/src/hooks/useFetch.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
|
||||||
|
const useFetch = <R>(resource: string) => {
|
||||||
|
const base = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const [data, setData] = useState<R>()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
|
const fetcher = () => pipe(
|
||||||
|
ffetch<R>(`${base}${resource}`),
|
||||||
|
matchW(
|
||||||
|
(l) => {
|
||||||
|
setError(l)
|
||||||
|
pushMessage(l, 'error')
|
||||||
|
},
|
||||||
|
(r) => setData(r)
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetcher()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { data, error, fetcher }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useFetch
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useAtomValue } from 'jotai'
|
import Translator from '../lib/i18n'
|
||||||
import { i18nBuilderState } from '../atoms/i18n'
|
|
||||||
|
|
||||||
export const useI18n = () => {
|
export const useI18n = () => {
|
||||||
const instance = useAtomValue(i18nBuilderState)
|
const instance = Translator.instance
|
||||||
|
|
||||||
return {
|
return {
|
||||||
i18n: instance,
|
i18n: instance,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { tryCatch } from 'fp-ts/TaskEither'
|
import { tryCatch } from 'fp-ts/TaskEither'
|
||||||
|
import * as J from 'fp-ts/Json'
|
||||||
|
import * as E from 'fp-ts/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
|
||||||
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise<string> {
|
||||||
() => fetcher<T>(url, opt),
|
|
||||||
(e) => `error while fetching: ${e}`
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
|
||||||
const jwt = localStorage.getItem('token')
|
const jwt = localStorage.getItem('token')
|
||||||
|
|
||||||
if (opt && !opt.headers) {
|
if (opt && !opt.headers) {
|
||||||
@@ -20,11 +17,27 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
|||||||
headers: {
|
headers: {
|
||||||
...opt?.headers,
|
...opt?.headers,
|
||||||
'X-Authentication': jwt ?? ''
|
'X-Authentication': jwt ?? ''
|
||||||
}
|
},
|
||||||
|
signal: controller?.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw await res.text()
|
throw await res.text()
|
||||||
}
|
}
|
||||||
return res.json() as T
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ffetch = <T>(url: string, opt?: RequestInit, controller?: AbortController) => tryCatch(
|
||||||
|
async () => pipe(
|
||||||
|
await fetcher(url, opt, controller),
|
||||||
|
J.parse,
|
||||||
|
E.match(
|
||||||
|
(l) => l as T,
|
||||||
|
(r) => r as T
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(e) => `error while fetching: ${e}`
|
||||||
|
)
|
||||||
|
|||||||
53
frontend/src/lib/i18n.ts
Normal file
53
frontend/src/lib/i18n.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//@ts-ignore
|
||||||
|
import i18n from '../assets/i18n.yaml'
|
||||||
|
//@ts-ignore
|
||||||
|
import fallback from '../assets/i18n/en_US.yaml'
|
||||||
|
|
||||||
|
export default class Translator {
|
||||||
|
static #instance: Translator
|
||||||
|
|
||||||
|
private language: string
|
||||||
|
private current: string[] = []
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.language = localStorage.getItem('language')?.replaceAll('"', '') ?? 'english'
|
||||||
|
this.setLanguage(this.language)
|
||||||
|
}
|
||||||
|
|
||||||
|
getLanguage(): string {
|
||||||
|
return this.language
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLanguage(language: string): Promise<void> {
|
||||||
|
this.language = language
|
||||||
|
|
||||||
|
let isoCodeFile: string = i18n.languages[language]
|
||||||
|
|
||||||
|
// extension needs to be in source code to help vite bundle all yaml files
|
||||||
|
if (isoCodeFile.endsWith('.yaml')) {
|
||||||
|
isoCodeFile = isoCodeFile.replaceAll('.yaml', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isoCodeFile) {
|
||||||
|
const { default: translations } = await import(`../assets/i18n/${isoCodeFile}.yaml`)
|
||||||
|
|
||||||
|
this.current = translations.keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t(key: string): string {
|
||||||
|
if (this.current) {
|
||||||
|
//@ts-ignore
|
||||||
|
return this.current[key] ?? fallback.keys[key] ?? 'caption not defined'
|
||||||
|
}
|
||||||
|
return 'caption not defined'
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get instance(): Translator {
|
||||||
|
if (!Translator.#instance) {
|
||||||
|
Translator.#instance = new Translator()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Translator.#instance
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import i18n from "../assets/i18n.yaml"
|
|
||||||
|
|
||||||
export default class I18nBuilder {
|
|
||||||
private language: string
|
|
||||||
private textMap = i18n.languages
|
|
||||||
private current: string[]
|
|
||||||
|
|
||||||
constructor(language: string) {
|
|
||||||
this.setLanguage(language)
|
|
||||||
}
|
|
||||||
|
|
||||||
getLanguage(): string {
|
|
||||||
return this.language
|
|
||||||
}
|
|
||||||
|
|
||||||
setLanguage(language: string): void {
|
|
||||||
this.language = language
|
|
||||||
this.current = this.textMap[this.language]
|
|
||||||
}
|
|
||||||
|
|
||||||
t(key: string): string {
|
|
||||||
if (this.current) {
|
|
||||||
return this.current[key] ?? 'caption not defined'
|
|
||||||
}
|
|
||||||
return 'caption not defined'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,11 +41,13 @@ export class RPCClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private argsSanitizer(args: string) {
|
private argsSanitizer(args: string): string[] {
|
||||||
|
const splitOnlyWhitespaces = /[^\s"']+|"([^"]*)"|'([^']*)'/gm
|
||||||
|
|
||||||
return args
|
return args
|
||||||
.split(' ')
|
.match(splitOnlyWhitespaces)
|
||||||
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
|
?.map(a => a.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendHTTP<T>(req: RPCRequest) {
|
private async sendHTTP<T>(req: RPCRequest) {
|
||||||
@@ -128,21 +130,21 @@ export class RPCClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public kill(id: string) {
|
public kill(id: string) {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.Kill',
|
method: 'Service.Kill',
|
||||||
params: [id],
|
params: [id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear(id: string) {
|
public clear(id: string) {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.Clear',
|
method: 'Service.Clear',
|
||||||
params: [id],
|
params: [id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public killAll() {
|
public killAll() {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.KillAll',
|
method: 'Service.KillAll',
|
||||||
params: [],
|
params: [],
|
||||||
})
|
})
|
||||||
@@ -198,4 +200,11 @@ export class RPCClient {
|
|||||||
params: []
|
params: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public clearCompleted() {
|
||||||
|
return this.sendHTTP({
|
||||||
|
method: 'Service.ClearCompleted',
|
||||||
|
params: []
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,12 @@ import Terminal from './views/Terminal'
|
|||||||
|
|
||||||
const Home = lazy(() => import('./views/Home'))
|
const Home = lazy(() => import('./views/Home'))
|
||||||
const Login = lazy(() => import('./views/Login'))
|
const Login = lazy(() => import('./views/Login'))
|
||||||
|
const Twitch = lazy(() => import('./views/Twitch'))
|
||||||
const Archive = lazy(() => import('./views/Archive'))
|
const Archive = lazy(() => import('./views/Archive'))
|
||||||
const Settings = lazy(() => import('./views/Settings'))
|
const Settings = lazy(() => import('./views/Settings'))
|
||||||
const LiveStream = lazy(() => import('./views/Livestream'))
|
const LiveStream = lazy(() => import('./views/Livestream'))
|
||||||
|
const Filebrowser = lazy(() => import('./views/Filebrowser'))
|
||||||
|
const Subscriptions = lazy(() => import('./views/Subscriptions'))
|
||||||
|
|
||||||
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
||||||
|
|
||||||
@@ -59,6 +62,32 @@ export const router = createHashRouter([
|
|||||||
</Suspense >
|
</Suspense >
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/filebrowser',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Filebrowser />
|
||||||
|
</Suspense >
|
||||||
|
),
|
||||||
|
errorElement: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<ErrorBoundary />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/subscriptions',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Subscriptions />
|
||||||
|
</Suspense >
|
||||||
|
),
|
||||||
|
errorElement: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<ErrorBoundary />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
element: (
|
element: (
|
||||||
@@ -83,6 +112,14 @@ export const router = createHashRouter([
|
|||||||
</Suspense >
|
</Suspense >
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/twitch',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Twitch />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
37
frontend/src/services/subscriptions.ts
Normal file
37
frontend/src/services/subscriptions.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// import { PaginatedResponse } from '../types'
|
||||||
|
|
||||||
|
export type Subscription = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
params: string
|
||||||
|
cron_expression: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// class SubscriptionService {
|
||||||
|
// private _baseURL: string = ''
|
||||||
|
|
||||||
|
// public set baseURL(v: string) {
|
||||||
|
// this._baseURL = v
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async delete(id: string): Promise<void> {
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async listPaginated(start: number, limit: number = 50): Promise<PaginatedResponse<Subscription[]>> {
|
||||||
|
// const res = await fetch(`${this._baseURL}/subscriptions?id=${start}&limit=${limit}`)
|
||||||
|
// const data: PaginatedResponse<Subscription[]> = await res.json()
|
||||||
|
|
||||||
|
// return data
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async submit(sub: Subscription): Promise<void> {
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async edit(sub: Subscription): Promise<void> {
|
||||||
|
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export default SubscriptionService
|
||||||
@@ -13,6 +13,7 @@ export type RPCMethods =
|
|||||||
| "Service.ProgressLivestream"
|
| "Service.ProgressLivestream"
|
||||||
| "Service.KillLivestream"
|
| "Service.KillLivestream"
|
||||||
| "Service.KillAllLivestream"
|
| "Service.KillAllLivestream"
|
||||||
|
| "Service.ClearCompleted"
|
||||||
|
|
||||||
export type RPCRequest = {
|
export type RPCRequest = {
|
||||||
method: RPCMethods
|
method: RPCMethods
|
||||||
@@ -122,4 +123,20 @@ export type LiveStreamProgress = Record<string, {
|
|||||||
export type RPCVersion = {
|
export type RPCVersion = {
|
||||||
rpcVersion: string
|
rpcVersion: string
|
||||||
ytdlpVersion: string
|
ytdlpVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArchiveEntry = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
thumbnail: string
|
||||||
|
source: string
|
||||||
|
metadata: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaginatedResponse<T> = {
|
||||||
|
first: number
|
||||||
|
next: number
|
||||||
|
data: T
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { blue, red } from '@mui/material/colors'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { Accent, ThemeNarrowed } from './atoms/settings'
|
||||||
import type { RPCResponse } from "./types"
|
import type { RPCResponse } from "./types"
|
||||||
import { ProcessStatus } from './types'
|
import { ProcessStatus } from './types'
|
||||||
|
|
||||||
@@ -79,4 +81,15 @@ export const base64URLEncode = (s: string) => pipe(
|
|||||||
s => String.fromCodePoint(...new TextEncoder().encode(s)),
|
s => String.fromCodePoint(...new TextEncoder().encode(s)),
|
||||||
btoa,
|
btoa,
|
||||||
encodeURIComponent
|
encodeURIComponent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const getAccentValue = (accent: Accent, mode: ThemeNarrowed) => {
|
||||||
|
switch (accent) {
|
||||||
|
case 'default':
|
||||||
|
return mode === 'light' ? blue[700] : blue[300]
|
||||||
|
case 'red':
|
||||||
|
return mode === 'light' ? red[600] : red[400]
|
||||||
|
default:
|
||||||
|
return mode === 'light' ? blue[700] : blue[300]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,363 +1,112 @@
|
|||||||
import {
|
import { Container, FormControl, Grid2, InputLabel, MenuItem, Pagination, Select } from '@mui/material'
|
||||||
Backdrop,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
CircularProgress,
|
|
||||||
Container,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
MenuItem,
|
|
||||||
MenuList,
|
|
||||||
Paper,
|
|
||||||
SpeedDial,
|
|
||||||
SpeedDialAction,
|
|
||||||
SpeedDialIcon,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
|
||||||
import FolderIcon from '@mui/icons-material/Folder'
|
|
||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
|
||||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
|
||||||
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download'
|
|
||||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
|
||||||
import { serverURL } from '../atoms/settings'
|
|
||||||
import { useObservable } from '../hooks/observable'
|
|
||||||
import { useToast } from '../hooks/toast'
|
|
||||||
import { useI18n } from '../hooks/useI18n'
|
|
||||||
import { ffetch } from '../lib/httpClient'
|
|
||||||
import { DirectoryEntry } from '../types'
|
|
||||||
import { base64URLEncode, formatSize } from '../utils'
|
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useEffect, useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import ArchiveCard from '../components/ArchiveCard'
|
||||||
|
import EmptyArchive from '../components/EmptyArchive'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { ArchiveEntry, PaginatedResponse } from '../types'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
|
||||||
export default function Downloaded() {
|
const Archive: React.FC = () => {
|
||||||
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
const [isLoading, setLoading] = useState(true)
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [archiveEntries, setArchiveEntries] = useState<ArchiveEntry[]>()
|
||||||
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
|
||||||
|
|
||||||
const serverAddr = useAtomValue(serverURL)
|
const [currentCursor, setCurrentCursor] = useState(0)
|
||||||
const navigate = useNavigate()
|
const [cursor, setCursor] = useState({ first: 0, next: 0 })
|
||||||
|
const [pageSize, setPageSize] = useState(25)
|
||||||
const { i18n } = useI18n()
|
|
||||||
const { pushMessage } = useToast()
|
|
||||||
|
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
|
||||||
|
|
||||||
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
|
||||||
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const fetcher = () => pipe(
|
const serverAddr = useAtomValue(serverURL)
|
||||||
ffetch<DirectoryEntry[]>(
|
const { pushMessage } = useToast()
|
||||||
`${serverAddr}/archive/downloaded`,
|
|
||||||
{
|
const fetchArchived = (startCursor = 0) => pipe(
|
||||||
method: 'POST',
|
ffetch<PaginatedResponse<ArchiveEntry[]>>(`${serverAddr}/archive?id=${startCursor}&limit=${pageSize}`),
|
||||||
body: JSON.stringify({
|
|
||||||
subdir: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
),
|
|
||||||
matchW(
|
matchW(
|
||||||
(e) => {
|
(l) => pushMessage(l, 'error'),
|
||||||
pushMessage(e, 'error')
|
(r) => {
|
||||||
navigate('/login')
|
setArchiveEntries(r.data)
|
||||||
},
|
setCursor({ ...cursor, first: r.first, next: r.next })
|
||||||
(d) => files$.next(d ?? []),
|
}
|
||||||
)
|
)
|
||||||
)()
|
)()
|
||||||
|
|
||||||
const fetcherSubfolder = (sub: string) => {
|
const softDelete = (id: string) => pipe(
|
||||||
const folders = sub.startsWith('/')
|
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/soft/${id}`, {
|
||||||
? sub.substring(1).split('/')
|
method: 'DELETE'
|
||||||
: sub.split('/')
|
|
||||||
|
|
||||||
const relpath = folders.length >= 2
|
|
||||||
? folders.slice(-(folders.length - 1)).join('/')
|
|
||||||
: folders.pop()
|
|
||||||
|
|
||||||
const _upperLevel = folders.slice(1, -1)
|
|
||||||
const upperLevel = _upperLevel.length === 2
|
|
||||||
? ['.', ..._upperLevel].join('/')
|
|
||||||
: _upperLevel.join('/')
|
|
||||||
|
|
||||||
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ subdir: relpath })
|
|
||||||
})
|
|
||||||
|
|
||||||
pipe(
|
|
||||||
task,
|
|
||||||
matchW(
|
|
||||||
(l) => pushMessage(l, 'error'),
|
|
||||||
(r) => files$.next(sub
|
|
||||||
? [{
|
|
||||||
isDirectory: true,
|
|
||||||
isVideo: false,
|
|
||||||
modTime: '',
|
|
||||||
name: '..',
|
|
||||||
path: upperLevel,
|
|
||||||
size: 0,
|
|
||||||
}, ...r.filter(f => f.name !== '')]
|
|
||||||
: r.filter(f => f.name !== '')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)()
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectable$ = useMemo(() => files$.pipe(
|
|
||||||
combineLatestWith(selected$),
|
|
||||||
map(([data, selected]) => data.map(x => ({
|
|
||||||
...x,
|
|
||||||
selected: selected.includes(x.name)
|
|
||||||
}))),
|
|
||||||
share()
|
|
||||||
), [])
|
|
||||||
|
|
||||||
const selectable = useObservable(selectable$, [])
|
|
||||||
|
|
||||||
const addSelected = (name: string) => {
|
|
||||||
selected$.value.includes(name)
|
|
||||||
? selected$.next(selected$.value.filter(val => val !== name))
|
|
||||||
: selected$.next([...selected$.value, name])
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteFile = (entry: DirectoryEntry) => pipe(
|
|
||||||
ffetch(`${serverAddr}/archive/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
path: entry.path,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
matchW(
|
matchW(
|
||||||
(l) => pushMessage(l, 'error'),
|
(l) => pushMessage(l, 'error'),
|
||||||
(_) => fetcher()
|
(_) => startTransition(async () => await fetchArchived())
|
||||||
)
|
)
|
||||||
)()
|
)()
|
||||||
|
|
||||||
const deleteSelected = () => {
|
const hardDelete = (id: string) => pipe(
|
||||||
Promise.all(selectable
|
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/hard/${id}`, {
|
||||||
.filter(entry => entry.selected)
|
method: 'DELETE'
|
||||||
.map(deleteFile)
|
}),
|
||||||
).then(fetcher)
|
matchW(
|
||||||
}
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => startTransition(async () => await fetchArchived())
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const setPage = (page: number) => setCurrentCursor(pageSize * (page - 1))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetcher()
|
fetchArchived(currentCursor).then(() => setLoading(false))
|
||||||
}, [serverAddr])
|
}, [currentCursor])
|
||||||
|
|
||||||
const onFileClick = (path: string) => startTransition(() => {
|
|
||||||
const encoded = base64URLEncode(path)
|
|
||||||
|
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const downloadFile = (path: string) => startTransition(() => {
|
|
||||||
const encoded = base64URLEncode(path)
|
|
||||||
|
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const onFolderClick = (path: string) => startTransition(() => {
|
|
||||||
fetcherSubfolder(path)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8, minHeight: '80vh' }}>
|
||||||
maxWidth="xl"
|
<LoadingBackdrop isLoading={isPending || isLoading} />
|
||||||
sx={{ mt: 4, mb: 4, height: '100%' }}
|
{
|
||||||
onClick={() => setShowMenu(false)}
|
archiveEntries && archiveEntries.length !== 0 ?
|
||||||
>
|
<Grid2
|
||||||
<IconMenu
|
container
|
||||||
posX={menuPos.x}
|
spacing={{ xs: 2, md: 2 }}
|
||||||
posY={menuPos.y}
|
columns={{ xs: 4, sm: 8, md: 12, xl: 12 }}
|
||||||
hide={!showMenu}
|
pt={2}
|
||||||
onDownload={() => {
|
sx={{ minHeight: '77.5vh' }}
|
||||||
if (currentFile) {
|
|
||||||
downloadFile(currentFile?.path)
|
|
||||||
setCurrentFile(undefined)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
if (currentFile) {
|
|
||||||
deleteFile(currentFile)
|
|
||||||
setCurrentFile(undefined)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Backdrop
|
|
||||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
|
||||||
open={!(files$.observed) || isPending}
|
|
||||||
>
|
|
||||||
<CircularProgress color="primary" />
|
|
||||||
</Backdrop>
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
onClick={() => setShowMenu(false)}
|
|
||||||
>
|
|
||||||
<Typography py={1} variant="h5" color="primary">
|
|
||||||
{i18n.t('archiveTitle')}
|
|
||||||
</Typography>
|
|
||||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
|
||||||
{selectable.length === 0 && 'No files found'}
|
|
||||||
{selectable.map((file, idx) => (
|
|
||||||
<ListItem
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setCurrentFile(file)
|
|
||||||
setMenuPos({ x: e.clientX, y: e.clientY })
|
|
||||||
setShowMenu(true)
|
|
||||||
}}
|
|
||||||
key={idx}
|
|
||||||
secondaryAction={
|
|
||||||
<div>
|
|
||||||
{!file.isDirectory && <Typography
|
|
||||||
variant="caption"
|
|
||||||
component="span"
|
|
||||||
>
|
|
||||||
{formatSize(file.size)}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
{!file.isDirectory && <>
|
|
||||||
<Checkbox
|
|
||||||
edge="end"
|
|
||||||
checked={file.selected}
|
|
||||||
onChange={() => addSelected(file.name)}
|
|
||||||
/>
|
|
||||||
</>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
disablePadding
|
|
||||||
>
|
|
||||||
<ListItemButton onClick={
|
|
||||||
() => file.isDirectory
|
|
||||||
? onFolderClick(file.path)
|
|
||||||
: onFileClick(file.path)
|
|
||||||
}>
|
|
||||||
<ListItemIcon>
|
|
||||||
{file.isDirectory
|
|
||||||
? <FolderIcon />
|
|
||||||
: file.isVideo
|
|
||||||
? <VideoFileIcon />
|
|
||||||
: <InsertDriveFileIcon />
|
|
||||||
}
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={file.name}
|
|
||||||
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
<SpeedDial
|
|
||||||
ariaLabel='archive actions'
|
|
||||||
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
|
||||||
icon={<SpeedDialIcon />}
|
|
||||||
>
|
|
||||||
<SpeedDialAction
|
|
||||||
icon={<DeleteForeverIcon />}
|
|
||||||
tooltipTitle={`Delete selected`}
|
|
||||||
tooltipOpen
|
|
||||||
onClick={() => {
|
|
||||||
if (selected$.value.length > 0) {
|
|
||||||
setOpenDialog(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SpeedDial>
|
|
||||||
<Dialog
|
|
||||||
open={openDialog}
|
|
||||||
onClose={() => setOpenDialog(false)}
|
|
||||||
>
|
|
||||||
<DialogTitle>
|
|
||||||
Are you sure?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText id="alert-dialog-description">
|
|
||||||
You're deleting:
|
|
||||||
</DialogContentText>
|
|
||||||
<ul>
|
|
||||||
{selected$.value.map((entry, idx) => (
|
|
||||||
<li key={idx}>{entry}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setOpenDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
deleteSelected()
|
|
||||||
setOpenDialog(false)
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
>
|
>
|
||||||
Ok
|
{
|
||||||
</Button>
|
archiveEntries.map((entry) => (
|
||||||
</DialogActions>
|
<Grid2 size={{ xs: 4, sm: 8, md: 4, xl: 3 }} key={entry.id}>
|
||||||
</Dialog>
|
<ArchiveCard
|
||||||
|
entry={entry}
|
||||||
|
onDelete={() => startTransition(async () => await softDelete(entry.id))}
|
||||||
|
onHardDelete={() => startTransition(async () => await hardDelete(entry.id))}
|
||||||
|
/>
|
||||||
|
</Grid2>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Grid2>
|
||||||
|
: <EmptyArchive />
|
||||||
|
}
|
||||||
|
<Pagination
|
||||||
|
sx={{ mx: 'auto', pt: 2 }}
|
||||||
|
count={Math.floor(cursor.next / pageSize) + 1}
|
||||||
|
onChange={(_, v) => setPage(v)}
|
||||||
|
/>
|
||||||
|
<FormControl variant="standard" sx={{ m: 1, minWidth: 120 }}>
|
||||||
|
<InputLabel id="page-size-select-label">Page size</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="page-size-select-label"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||||
|
label="Page size"
|
||||||
|
>
|
||||||
|
<MenuItem value={25}>25</MenuItem>
|
||||||
|
<MenuItem value={50}>50</MenuItem>
|
||||||
|
<MenuItem value={100}>100</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconMenu: React.FC<{
|
export default Archive
|
||||||
posX: number
|
|
||||||
posY: number
|
|
||||||
hide: boolean
|
|
||||||
onDownload: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
|
|
||||||
return (
|
|
||||||
<Paper sx={{
|
|
||||||
width: 320,
|
|
||||||
maxWidth: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
top: posY,
|
|
||||||
left: posX,
|
|
||||||
display: hide ? 'none' : 'block',
|
|
||||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
|
||||||
}}>
|
|
||||||
<MenuList>
|
|
||||||
<MenuItem onClick={onDownload}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<DownloadIcon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>
|
|
||||||
Download
|
|
||||||
</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={onDelete}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<DeleteForeverIcon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>
|
|
||||||
Delete
|
|
||||||
</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
360
frontend/src/views/Filebrowser.tsx
Normal file
360
frontend/src/views/Filebrowser.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import {
|
||||||
|
Backdrop,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Paper,
|
||||||
|
SpeedDial,
|
||||||
|
SpeedDialAction,
|
||||||
|
SpeedDialIcon,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder'
|
||||||
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||||
|
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||||
|
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { useObservable } from '../hooks/observable'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { DirectoryEntry } from '../types'
|
||||||
|
import { base64URLEncode, formatSize } from '../utils'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
export default function Downloaded() {
|
||||||
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
||||||
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
||||||
|
|
||||||
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
||||||
|
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const fetcher = () => pipe(
|
||||||
|
ffetch<DirectoryEntry[]>(
|
||||||
|
`${serverAddr}/filebrowser/downloaded`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
subdir: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
),
|
||||||
|
matchW(
|
||||||
|
(e) => {
|
||||||
|
pushMessage(e, 'error')
|
||||||
|
navigate('/login')
|
||||||
|
},
|
||||||
|
(d) => files$.next(d ?? []),
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const fetcherSubfolder = (sub: string) => {
|
||||||
|
const folders = sub.startsWith('/')
|
||||||
|
? sub.substring(1).split('/')
|
||||||
|
: sub.split('/')
|
||||||
|
|
||||||
|
const relpath = folders.length >= 2
|
||||||
|
? folders.slice(-(folders.length - 1)).join('/')
|
||||||
|
: folders.pop()
|
||||||
|
|
||||||
|
const _upperLevel = folders.slice(1, -1)
|
||||||
|
const upperLevel = _upperLevel.length === 2
|
||||||
|
? ['.', ..._upperLevel].join('/')
|
||||||
|
: _upperLevel.join('/')
|
||||||
|
|
||||||
|
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/filebrowser/downloaded`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ subdir: relpath })
|
||||||
|
})
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
task,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(r) => files$.next(sub
|
||||||
|
? [{
|
||||||
|
isDirectory: true,
|
||||||
|
isVideo: false,
|
||||||
|
modTime: '',
|
||||||
|
name: '..',
|
||||||
|
path: upperLevel,
|
||||||
|
size: 0,
|
||||||
|
}, ...r.filter(f => f.name !== '')]
|
||||||
|
: r.filter(f => f.name !== '')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable$ = useMemo(() => files$.pipe(
|
||||||
|
combineLatestWith(selected$),
|
||||||
|
map(([data, selected]) => data.map(x => ({
|
||||||
|
...x,
|
||||||
|
selected: selected.includes(x.name)
|
||||||
|
}))),
|
||||||
|
share()
|
||||||
|
), [])
|
||||||
|
|
||||||
|
const selectable = useObservable(selectable$, [])
|
||||||
|
|
||||||
|
const addSelected = (name: string) => {
|
||||||
|
selected$.value.includes(name)
|
||||||
|
? selected$.next(selected$.value.filter(val => val !== name))
|
||||||
|
: selected$.next([...selected$.value, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFile = (entry: DirectoryEntry) => pipe(
|
||||||
|
ffetch(`${serverAddr}/filebrowser/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: entry.path,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => fetcher()
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const deleteSelected = () => {
|
||||||
|
Promise.all(selectable
|
||||||
|
.filter(entry => entry.selected)
|
||||||
|
.map(deleteFile)
|
||||||
|
).then(fetcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetcher()
|
||||||
|
}, [serverAddr])
|
||||||
|
|
||||||
|
const onFileClick = (path: string) => startTransition(() => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
|
||||||
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadFile = (path: string) => startTransition(() => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
|
||||||
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFolderClick = (path: string) => startTransition(() => {
|
||||||
|
fetcherSubfolder(path)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
maxWidth="xl"
|
||||||
|
sx={{ mt: 4, mb: 4, minHeight: '100%' }}
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<IconMenu
|
||||||
|
posX={menuPos.x}
|
||||||
|
posY={menuPos.y}
|
||||||
|
hide={!showMenu}
|
||||||
|
onDownload={() => {
|
||||||
|
if (currentFile) {
|
||||||
|
downloadFile(currentFile?.path)
|
||||||
|
setCurrentFile(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
if (currentFile) {
|
||||||
|
deleteFile(currentFile)
|
||||||
|
setCurrentFile(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Backdrop
|
||||||
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||||
|
open={!(files$.observed) || isPending}
|
||||||
|
>
|
||||||
|
<CircularProgress color="primary" />
|
||||||
|
</Backdrop>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||||
|
{selectable.length === 0 && i18n.t('noFilesFound')}
|
||||||
|
{selectable.map((file, idx) => (
|
||||||
|
<ListItem
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCurrentFile(file)
|
||||||
|
setMenuPos({ x: e.clientX, y: e.clientY })
|
||||||
|
setShowMenu(true)
|
||||||
|
}}
|
||||||
|
key={idx}
|
||||||
|
secondaryAction={
|
||||||
|
<div>
|
||||||
|
{!file.isDirectory && <Typography
|
||||||
|
variant="caption"
|
||||||
|
component="span"
|
||||||
|
>
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
{!file.isDirectory && <>
|
||||||
|
<Checkbox
|
||||||
|
edge="end"
|
||||||
|
checked={file.selected}
|
||||||
|
onChange={() => addSelected(file.name)}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
disablePadding
|
||||||
|
>
|
||||||
|
<ListItemButton onClick={
|
||||||
|
() => file.isDirectory
|
||||||
|
? onFolderClick(file.path)
|
||||||
|
: onFileClick(file.path)
|
||||||
|
}>
|
||||||
|
<ListItemIcon>
|
||||||
|
{file.isDirectory
|
||||||
|
? <FolderIcon />
|
||||||
|
: file.isVideo
|
||||||
|
? <VideoFileIcon />
|
||||||
|
: <InsertDriveFileIcon />
|
||||||
|
}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={file.name}
|
||||||
|
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel='archive actions'
|
||||||
|
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||||
|
icon={<SpeedDialIcon />}
|
||||||
|
>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<DeleteForeverIcon />}
|
||||||
|
tooltipTitle={i18n.t('deleteSelected')}
|
||||||
|
tooltipOpen
|
||||||
|
onClick={() => {
|
||||||
|
if (selected$.value.length > 0) {
|
||||||
|
setOpenDialog(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SpeedDial>
|
||||||
|
<Dialog
|
||||||
|
open={openDialog}
|
||||||
|
onClose={() => setOpenDialog(false)}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Are you sure?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText id="alert-dialog-description">
|
||||||
|
You're deleting:
|
||||||
|
</DialogContentText>
|
||||||
|
<ul>
|
||||||
|
{selected$.value.map((entry, idx) => (
|
||||||
|
<li key={idx}>{entry}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpenDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
deleteSelected()
|
||||||
|
setOpenDialog(false)
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
Ok
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconMenu: React.FC<{
|
||||||
|
posX: number
|
||||||
|
posY: number
|
||||||
|
hide: boolean
|
||||||
|
onDownload: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
|
||||||
|
return (
|
||||||
|
<Paper sx={{
|
||||||
|
width: 320,
|
||||||
|
maxWidth: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
top: posY,
|
||||||
|
left: posX,
|
||||||
|
display: hide ? 'none' : 'block',
|
||||||
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
|
}}>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem onClick={onDownload}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DownloadIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
Download
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onDelete}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DeleteForeverIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
Delete
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { interval } from 'rxjs'
|
import { interval } from 'rxjs'
|
||||||
|
import { rpcPollingTimeState } from '../atoms/rpc'
|
||||||
import LivestreamDialog from '../components/livestream/LivestreamDialog'
|
import LivestreamDialog from '../components/livestream/LivestreamDialog'
|
||||||
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
|
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
|
||||||
import NoLivestreams from '../components/livestream/NoLivestreams'
|
import NoLivestreams from '../components/livestream/NoLivestreams'
|
||||||
@@ -24,7 +26,9 @@ const LiveStreamMonitorView: React.FC = () => {
|
|||||||
const [progress, setProgress] = useState<LiveStreamProgress>()
|
const [progress, setProgress] = useState<LiveStreamProgress>()
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
useSubscription(interval(1000), () => {
|
const rpcPollingRate = useAtomValue(rpcPollingTimeState)
|
||||||
|
|
||||||
|
useSubscription(interval(rpcPollingRate), () => {
|
||||||
client
|
client
|
||||||
.progressLivestream()
|
.progressLivestream()
|
||||||
.then(r => setProgress(r.result))
|
.then(r => setProgress(r.result))
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function Login() {
|
|||||||
</Title>
|
</Title>
|
||||||
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
||||||
To configure authentication check the
|
To configure authentication check the
|
||||||
<a href='https://github.com/marcopeocchi/yt-dlp-web-ui/wiki/Authentication-methods'>wiki</a>.
|
<a href='https://github.com/marcopiovanello/yt-dlp-web-ui/wiki/Authentication-methods'>wiki</a>.
|
||||||
</Title>
|
</Title>
|
||||||
<TextField
|
<TextField
|
||||||
label="Username"
|
label="Username"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
capitalize
|
capitalize
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Suspense, useEffect, useMemo, useState } from 'react'
|
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Subject,
|
Subject,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -28,11 +28,15 @@ import {
|
|||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
import { rpcPollingTimeState } from '../atoms/rpc'
|
import { rpcPollingTimeState } from '../atoms/rpc'
|
||||||
import {
|
import {
|
||||||
|
Accent,
|
||||||
Language,
|
Language,
|
||||||
Theme,
|
Theme,
|
||||||
|
accentState,
|
||||||
|
accents,
|
||||||
appTitleState,
|
appTitleState,
|
||||||
enableCustomArgsState,
|
enableCustomArgsState,
|
||||||
fileRenamingState,
|
fileRenamingState,
|
||||||
|
autoFileExtensionState,
|
||||||
formatSelectionState,
|
formatSelectionState,
|
||||||
languageState,
|
languageState,
|
||||||
languages,
|
languages,
|
||||||
@@ -47,6 +51,7 @@ import CookiesTextField from '../components/CookiesTextField'
|
|||||||
import UpdateBinaryButton from '../components/UpdateBinaryButton'
|
import UpdateBinaryButton from '../components/UpdateBinaryButton'
|
||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import Translator from '../lib/i18n'
|
||||||
import { validateDomain, validateIP } from '../utils'
|
import { validateDomain, validateIP } from '../utils'
|
||||||
|
|
||||||
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
|
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
|
||||||
@@ -57,6 +62,7 @@ export default function Settings() {
|
|||||||
const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
|
const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
|
||||||
const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
|
const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
|
||||||
const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
|
const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
|
||||||
|
const [autoFileExtension, setAutoFileExtension] = useAtom(autoFileExtensionState)
|
||||||
const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
|
const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
|
||||||
|
|
||||||
const [serverAddr, setServerAddr] = useAtom(serverAddressState)
|
const [serverAddr, setServerAddr] = useAtom(serverAddressState)
|
||||||
@@ -65,6 +71,7 @@ export default function Settings() {
|
|||||||
const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState)
|
const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState)
|
||||||
const [language, setLanguage] = useAtom(languageState)
|
const [language, setLanguage] = useAtom(languageState)
|
||||||
const [appTitle, setApptitle] = useAtom(appTitleState)
|
const [appTitle, setApptitle] = useAtom(appTitleState)
|
||||||
|
const [accent, setAccent] = useAtom(accentState)
|
||||||
|
|
||||||
const [theme, setTheme] = useAtom(themeState)
|
const [theme, setTheme] = useAtom(themeState)
|
||||||
|
|
||||||
@@ -78,6 +85,9 @@ export default function Settings() {
|
|||||||
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
||||||
const serverPort$ = useMemo(() => new Subject<string>(), [])
|
const serverPort$ = useMemo(() => new Subject<string>(), [])
|
||||||
|
|
||||||
|
const [, updateState] = useState({})
|
||||||
|
const forceUpdate = useCallback(() => updateState({}), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sub = baseURL$
|
const sub = baseURL$
|
||||||
.pipe(debounceTime(500))
|
.pipe(debounceTime(500))
|
||||||
@@ -129,6 +139,11 @@ export default function Settings() {
|
|||||||
*/
|
*/
|
||||||
const handleLanguageChange = (event: SelectChangeEvent<Language>) => {
|
const handleLanguageChange = (event: SelectChangeEvent<Language>) => {
|
||||||
setLanguage(event.target.value as Language)
|
setLanguage(event.target.value as Language)
|
||||||
|
|
||||||
|
Translator.instance.setLanguage(event.target.value)
|
||||||
|
setTimeout(() => {
|
||||||
|
forceUpdate()
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,7 +191,6 @@ export default function Settings() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={12}>
|
<Grid item xs={12} md={12}>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={reverseProxy}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n.t('appTitle')}
|
label={i18n.t('appTitle')}
|
||||||
defaultValue={appTitle}
|
defaultValue={appTitle}
|
||||||
@@ -248,7 +262,7 @@ export default function Settings() {
|
|||||||
Appearance
|
Appearance
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12}>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
|
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -272,15 +286,31 @@ export default function Settings() {
|
|||||||
label={i18n.t('themeSelect')}
|
label={i18n.t('themeSelect')}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
>
|
>
|
||||||
<MenuItem value="light">Light</MenuItem>
|
<MenuItem value="light">{i18n.t('lightThemeButton')}</MenuItem>
|
||||||
<MenuItem value="dark">Dark</MenuItem>
|
<MenuItem value="dark">{i18n.t('darkThemeButton')}</MenuItem>
|
||||||
<MenuItem value="system">System</MenuItem>
|
<MenuItem value="system">System</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>{i18n.t('accentSelect')}</InputLabel>
|
||||||
|
<Select
|
||||||
|
defaultValue={accent}
|
||||||
|
label={i18n.t('accentSelect')}
|
||||||
|
onChange={(e) => setAccent(e.target.value as Accent)}
|
||||||
|
>
|
||||||
|
{accents.map((accent) => (
|
||||||
|
<MenuItem key={accent} value={accent}>
|
||||||
|
{capitalize(accent)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
|
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
|
||||||
General download settings
|
{i18n.t('generalDownloadSettings')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -315,12 +345,30 @@ export default function Settings() {
|
|||||||
<Switch
|
<Switch
|
||||||
defaultChecked={fileRenaming}
|
defaultChecked={fileRenaming}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
|
if (fileRenaming) {
|
||||||
|
setAutoFileExtension(false)
|
||||||
|
}
|
||||||
setFileRenaming(state => !state)
|
setFileRenaming(state => !state)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={i18n.t('filenameOverrideOption')}
|
label={i18n.t('filenameOverrideOption')}
|
||||||
/>
|
/>
|
||||||
|
{
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
disabled={!fileRenaming}
|
||||||
|
checked={fileRenaming ? autoFileExtension : false}
|
||||||
|
defaultChecked={autoFileExtension}
|
||||||
|
onChange={() => {
|
||||||
|
setAutoFileExtension(state => !state)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={i18n.t('autoFileExtensionOption')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
157
frontend/src/views/Subscriptions.tsx
Normal file
157
frontend/src/views/Subscriptions.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Table, TableBody, TableCell, TableContainer,
|
||||||
|
TableHead, TablePagination, TableRow
|
||||||
|
} from '@mui/material'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||||
|
import SubscriptionsDialog from '../components/subscriptions/SubscriptionsDialog'
|
||||||
|
import SubscriptionsEditDialog from '../components/subscriptions/SubscriptionsEditDialog'
|
||||||
|
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import useFetch from '../hooks/useFetch'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { Subscription } from '../services/subscriptions'
|
||||||
|
import { PaginatedResponse } from '../types'
|
||||||
|
|
||||||
|
const SubscriptionsView: React.FC = () => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const [selectedSubscription, setSelectedSubscription] = useState<Subscription>()
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const [startId, setStartId] = useState(0)
|
||||||
|
const [limit, setLimit] = useState(9)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const { data: subs, fetcher: refecth } = useFetch<PaginatedResponse<Subscription[]>>(
|
||||||
|
`/subscriptions?id=${startId}&limit=${limit}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const deleteSubscription = async (id: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/subscriptions/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
() => refecth()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingBackdrop isLoading={!subs || isPending} />
|
||||||
|
|
||||||
|
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||||
|
|
||||||
|
<SubscriptionsEditDialog
|
||||||
|
subscription={selectedSubscription}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedSubscription(undefined)
|
||||||
|
refecth()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SubscriptionsDialog open={openDialog} onClose={() => {
|
||||||
|
setOpenDialog(s => !s)
|
||||||
|
refecth()
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{!subs || subs.data.length === 0 ?
|
||||||
|
<NoSubscriptions /> :
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||||
|
<Paper sx={{
|
||||||
|
p: 2.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '80vh',
|
||||||
|
}}>
|
||||||
|
<TableContainer component={Box}>
|
||||||
|
<Table sx={{ minWidth: '100%' }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="left">URL</TableCell>
|
||||||
|
<TableCell align="right">Params</TableCell>
|
||||||
|
<TableCell align="right">{i18n.t('cronExpressionLabel')}</TableCell>
|
||||||
|
<TableCell align="center">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody sx={{ mb: 'auto' }}>
|
||||||
|
{subs.data.map(x => (
|
||||||
|
<TableRow
|
||||||
|
key={x.id}
|
||||||
|
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||||
|
>
|
||||||
|
<TableCell>{x.url}</TableCell>
|
||||||
|
<TableCell align='right'>
|
||||||
|
{x.params}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align='right'>
|
||||||
|
{x.cron_expression}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align='center'>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
size='small'
|
||||||
|
sx={{ mr: 0.5 }}
|
||||||
|
onClick={() => setSelectedSubscription(x)}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
size='small'
|
||||||
|
onClick={() => startTransition(async () => await deleteSubscription(x.id))}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={-1}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_, p) => {
|
||||||
|
if (p < page) {
|
||||||
|
setPage(s => (s - 1 <= 0 ? 0 : s - 1))
|
||||||
|
setStartId(subs.first)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPage(s => s + 1)
|
||||||
|
setStartId(subs.next)
|
||||||
|
}}
|
||||||
|
rowsPerPage={limit}
|
||||||
|
rowsPerPageOptions={[9, 10, 25, 50, 100]}
|
||||||
|
onRowsPerPageChange={(e) => { setLimit(parseInt(e.target.value)) }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Container>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsView
|
||||||
77
frontend/src/views/Twitch.tsx
Normal file
77
frontend/src/views/Twitch.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Chip,
|
||||||
|
Container,
|
||||||
|
Paper
|
||||||
|
} from '@mui/material'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||||
|
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||||
|
import TwitchDialog from '../components/twitch/TwitchDialog'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import useFetch from '../hooks/useFetch'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
|
||||||
|
const TwitchView: React.FC = () => {
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const { data: users, fetcher: refetch } = useFetch<Array<string>>('/twitch/users')
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const deleteUser = async (user: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/twitch/user/${user}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
() => refetch()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingBackdrop isLoading={!users || isPending} />
|
||||||
|
|
||||||
|
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||||
|
|
||||||
|
<TwitchDialog open={openDialog} onClose={() => {
|
||||||
|
setOpenDialog(s => !s)
|
||||||
|
refetch()
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{
|
||||||
|
!users || users.length === 0 ?
|
||||||
|
<NoSubscriptions /> :
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||||
|
<Paper sx={{
|
||||||
|
p: 2.5,
|
||||||
|
minHeight: '80vh',
|
||||||
|
}}>
|
||||||
|
{users.map(user => (
|
||||||
|
<Chip
|
||||||
|
label={user}
|
||||||
|
onDelete={() => startTransition(async () => await deleteUser(user))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchView
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react-swc'
|
||||||
import million from 'million/compiler'
|
|
||||||
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(() => {
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
million.vite({ auto: true }),
|
|
||||||
react(),
|
react(),
|
||||||
ViteYaml(),
|
ViteYaml(),
|
||||||
],
|
],
|
||||||
33
go.mod
33
go.mod
@@ -1,37 +1,32 @@
|
|||||||
module github.com/marcopeocchi/yt-dlp-web-ui/v3
|
module github.com/marcopiovanello/yt-dlp-web-ui/v3
|
||||||
|
|
||||||
go 1.23
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0
|
github.com/coreos/go-oidc/v3 v3.12.0
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/chi/v5 v5.2.0
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
golang.org/x/oauth2 v0.23.0
|
github.com/robfig/cron/v3 v3.0.0
|
||||||
golang.org/x/sync v0.8.0
|
golang.org/x/oauth2 v0.25.0
|
||||||
golang.org/x/sys v0.25.0
|
golang.org/x/sync v0.10.0
|
||||||
|
golang.org/x/sys v0.29.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.33.1
|
modernc.org/sqlite v1.34.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.0.4 // 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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/crypto v0.27.0 // indirect
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
modernc.org/libc v1.61.11 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
modernc.org/memory v1.8.2 // 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
|
|
||||||
)
|
)
|
||||||
|
|||||||
90
go.sum
90
go.sum
@@ -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 h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
|
||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
|
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.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
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 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
|
github.com/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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.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.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||||
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
|
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 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
modernc.org/ccgo/v4 v4.23.15 h1:wFDan71KnYqeHz4eF63vmGE6Q6Pc0PUGDpP0PRMYjDc=
|
||||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
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 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
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.6.2 h1:YBXi5Kqp6aCK3fIxwKQ3/fErvawVKwjOLItxj1brGds=
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/gc/v2 v2.6.2/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
modernc.org/libc v1.61.11 h1:6sZG8uB6EMMG7iTLPTndi8jyTdgAQNIeLGjCFICACZw=
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/libc v1.61.11/go.mod h1:HHX+srFdn839oaJRd0W8hBM3eg+mieyZCAjWwB08/nM=
|
||||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
8
main.go
8
main.go
@@ -8,10 +8,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/cli"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/cli"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
version = "v3.1.2";
|
version = "v3.1.2";
|
||||||
meta = {
|
meta = {
|
||||||
description = "A terrible web ui for yt-dlp. Designed to be self-hosted.";
|
description = "A terrible web ui for yt-dlp. Designed to be self-hosted.";
|
||||||
homepage = "https://github.com/marcopeocchi/yt-dlp-web-ui";
|
homepage = "https://github.com/marcopiovanello/yt-dlp-web-ui";
|
||||||
license = lib.licenses.mpl20;
|
license = lib.licenses.mpl20;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Swagger yt-dlp-webui - OpenAPI 3.1",
|
"title": "Swagger yt-dlp-webui - OpenAPI 3.1",
|
||||||
"description": "yt-dlp-webui api based on the OpenAPI 3.1 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). \n\nSome useful links:\n- [yt-dlp-webui repository](https://github.com/marcopeocchi/yt-dlp-web-ui)",
|
"description": "yt-dlp-webui api based on the OpenAPI 3.1 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). \n\nSome useful links:\n- [yt-dlp-webui repository](https://github.com/marcopiovanello/yt-dlp-web-ui)",
|
||||||
"termsOfService": "http://swagger.io/terms/",
|
"termsOfService": "http://swagger.io/terms/",
|
||||||
"contact": {
|
"contact": {
|
||||||
"email": "apiteam@swagger.io"
|
"email": "apiteam@swagger.io"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"description": "Everything about your Pets",
|
"description": "Everything about your Pets",
|
||||||
"externalDocs": {
|
"externalDocs": {
|
||||||
"description": "Find out more",
|
"description": "Find out more",
|
||||||
"url": "https://github.com/marcopeocchi/yt-dlp-web-ui"
|
"url": "https://github.com/marcopiovanello/yt-dlp-web-ui"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
18
server/archive/archive.go
Normal file
18
server/archive/archive.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// alias type
|
||||||
|
// TODO: remove after refactoring
|
||||||
|
type Service = domain.Service
|
||||||
|
type Entity = domain.ArchiveEntry
|
||||||
|
|
||||||
|
func ApplyRouter(db *sql.DB) func(chi.Router) {
|
||||||
|
handler, _ := Container(db)
|
||||||
|
return handler.ApplyRouter()
|
||||||
|
}
|
||||||
16
server/archive/container.go
Normal file
16
server/archive/container.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Container(db *sql.DB) (domain.RestHandler, domain.Service) {
|
||||||
|
var (
|
||||||
|
r = provideRepository(db)
|
||||||
|
s = provideService(r)
|
||||||
|
h = provideHandler(s)
|
||||||
|
)
|
||||||
|
return h, s
|
||||||
|
}
|
||||||
13
server/archive/data/models.go
Normal file
13
server/archive/data/models.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ArchiveEntry struct {
|
||||||
|
Id string
|
||||||
|
Title string
|
||||||
|
Path string
|
||||||
|
Thumbnail string
|
||||||
|
Source string
|
||||||
|
Metadata string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
51
server/archive/domain/archive.go
Normal file
51
server/archive/domain/archive.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchiveEntry struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedResponse[T any] struct {
|
||||||
|
First int64 `json:"first"`
|
||||||
|
Next int64 `json:"next"`
|
||||||
|
Data T `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
Archive(ctx context.Context, model *data.ArchiveEntry) error
|
||||||
|
SoftDelete(ctx context.Context, id string) (*data.ArchiveEntry, error)
|
||||||
|
HardDelete(ctx context.Context, id string) (*data.ArchiveEntry, error)
|
||||||
|
List(ctx context.Context, startRowId int, limit int) (*[]data.ArchiveEntry, error)
|
||||||
|
GetCursor(ctx context.Context, id string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
Archive(ctx context.Context, entity *ArchiveEntry) error
|
||||||
|
SoftDelete(ctx context.Context, id string) (*ArchiveEntry, error)
|
||||||
|
HardDelete(ctx context.Context, id string) (*ArchiveEntry, error)
|
||||||
|
List(ctx context.Context, startRowId int, limit int) (*PaginatedResponse[[]ArchiveEntry], error)
|
||||||
|
GetCursor(ctx context.Context, id string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RestHandler interface {
|
||||||
|
List() http.HandlerFunc
|
||||||
|
Archive() http.HandlerFunc
|
||||||
|
SoftDelete() http.HandlerFunc
|
||||||
|
HardDelete() http.HandlerFunc
|
||||||
|
GetCursor() http.HandlerFunc
|
||||||
|
ApplyRouter() func(chi.Router)
|
||||||
|
}
|
||||||
42
server/archive/provider.go
Normal file
42
server/archive/provider.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/repository"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/rest"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
repo domain.Repository
|
||||||
|
svc domain.Service
|
||||||
|
hand domain.RestHandler
|
||||||
|
|
||||||
|
repoOnce sync.Once
|
||||||
|
svcOnce sync.Once
|
||||||
|
handOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func provideRepository(db *sql.DB) domain.Repository {
|
||||||
|
repoOnce.Do(func() {
|
||||||
|
repo = repository.New(db)
|
||||||
|
})
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func provideService(r domain.Repository) domain.Service {
|
||||||
|
svcOnce.Do(func() {
|
||||||
|
svc = service.New(r)
|
||||||
|
})
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func provideHandler(s domain.Service) domain.RestHandler {
|
||||||
|
handOnce.Do(func() {
|
||||||
|
hand = rest.New(s)
|
||||||
|
})
|
||||||
|
return hand
|
||||||
|
}
|
||||||
156
server/archive/repository/repository.go
Normal file
156
server/archive/repository/repository.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB) domain.Repository {
|
||||||
|
return &Repository{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Archive(ctx context.Context, entry *data.ArchiveEntry) error {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"INSERT INTO archive (id, title, path, thumbnail, source, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
uuid.NewString(),
|
||||||
|
entry.Title,
|
||||||
|
entry.Path,
|
||||||
|
entry.Thumbnail,
|
||||||
|
entry.Source,
|
||||||
|
entry.Metadata,
|
||||||
|
entry.CreatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SoftDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var model data.ArchiveEntry
|
||||||
|
|
||||||
|
row := tx.QueryRowContext(ctx, "SELECT * FROM archive WHERE id = ?", id)
|
||||||
|
|
||||||
|
if err := row.Scan(
|
||||||
|
&model.Id,
|
||||||
|
&model.Title,
|
||||||
|
&model.Path,
|
||||||
|
&model.Thumbnail,
|
||||||
|
&model.Source,
|
||||||
|
&model.Metadata,
|
||||||
|
&model.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM archive WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) HardDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) {
|
||||||
|
entry, err := r.SoftDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(entry.Path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) List(ctx context.Context, startRowId int, limit int) (*[]data.ArchiveEntry, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
var entries []data.ArchiveEntry
|
||||||
|
|
||||||
|
// cursor based pagination
|
||||||
|
rows, err := conn.QueryContext(ctx, "SELECT rowid, * FROM archive WHERE rowid > ? LIMIT ?", startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var rowId int64
|
||||||
|
var entry data.ArchiveEntry
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&rowId,
|
||||||
|
&entry.Id,
|
||||||
|
&entry.Title,
|
||||||
|
&entry.Path,
|
||||||
|
&entry.Thumbnail,
|
||||||
|
&entry.Source,
|
||||||
|
&entry.Metadata,
|
||||||
|
&entry.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return &entries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetCursor(ctx context.Context, id string) (int64, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
row := conn.QueryRowContext(ctx, "SELECT rowid FROM archive WHERE id = ?", id)
|
||||||
|
|
||||||
|
var rowId int64
|
||||||
|
|
||||||
|
if err := row.Scan(&rowId); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowId, nil
|
||||||
|
}
|
||||||
162
server/archive/rest/handler.go
Normal file
162
server/archive/rest/handler.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
|
||||||
|
|
||||||
|
middlewares "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service domain.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(service domain.Service) domain.RestHandler {
|
||||||
|
return &Handler{
|
||||||
|
service: service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements domain.RestHandler.
|
||||||
|
func (h *Handler) List() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
var (
|
||||||
|
startRowIdParam = r.URL.Query().Get("id")
|
||||||
|
LimitParam = r.URL.Query().Get("limit")
|
||||||
|
)
|
||||||
|
|
||||||
|
startRowId, err := strconv.Atoi(startRowIdParam)
|
||||||
|
if err != nil {
|
||||||
|
startRowId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := strconv.Atoi(LimitParam)
|
||||||
|
if err != nil {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.service.List(r.Context(), startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive implements domain.RestHandler.
|
||||||
|
func (h *Handler) Archive() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
var req domain.ArchiveEntry
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.service.Archive(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode("ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete implements domain.RestHandler.
|
||||||
|
func (h *Handler) HardDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
res, err := h.service.HardDelete(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete implements domain.RestHandler.
|
||||||
|
func (h *Handler) SoftDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
res, err := h.service.SoftDelete(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCursor implements domain.RestHandler.
|
||||||
|
func (h *Handler) GetCursor() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
cursorId, err := h.service.GetCursor(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(cursorId); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRouter implements domain.RestHandler.
|
||||||
|
func (h *Handler) ApplyRouter() func(chi.Router) {
|
||||||
|
return func(r chi.Router) {
|
||||||
|
if config.Instance().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())
|
||||||
|
}
|
||||||
|
}
|
||||||
121
server/archive/service/service.go
Normal file
121
server/archive/service/service.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository domain.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository domain.Repository) domain.Service {
|
||||||
|
return &Service{
|
||||||
|
repository: repository,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive implements domain.Service.
|
||||||
|
func (s *Service) Archive(ctx context.Context, entity *domain.ArchiveEntry) error {
|
||||||
|
return s.repository.Archive(ctx, &data.ArchiveEntry{
|
||||||
|
Id: entity.Id,
|
||||||
|
Title: entity.Title,
|
||||||
|
Path: entity.Path,
|
||||||
|
Thumbnail: entity.Thumbnail,
|
||||||
|
Source: entity.Source,
|
||||||
|
Metadata: entity.Metadata,
|
||||||
|
CreatedAt: entity.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete implements domain.Service.
|
||||||
|
func (s *Service) HardDelete(ctx context.Context, id string) (*domain.ArchiveEntry, error) {
|
||||||
|
res, err := s.repository.HardDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.ArchiveEntry{
|
||||||
|
Id: res.Id,
|
||||||
|
Title: res.Title,
|
||||||
|
Path: res.Path,
|
||||||
|
Thumbnail: res.Thumbnail,
|
||||||
|
Source: res.Source,
|
||||||
|
Metadata: res.Metadata,
|
||||||
|
CreatedAt: res.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete implements domain.Service.
|
||||||
|
func (s *Service) SoftDelete(ctx context.Context, id string) (*domain.ArchiveEntry, error) {
|
||||||
|
res, err := s.repository.SoftDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.ArchiveEntry{
|
||||||
|
Id: res.Id,
|
||||||
|
Title: res.Title,
|
||||||
|
Path: res.Path,
|
||||||
|
Thumbnail: res.Thumbnail,
|
||||||
|
Source: res.Source,
|
||||||
|
Metadata: res.Metadata,
|
||||||
|
CreatedAt: res.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements domain.Service.
|
||||||
|
func (s *Service) List(
|
||||||
|
ctx context.Context,
|
||||||
|
startRowId int,
|
||||||
|
limit int,
|
||||||
|
) (*domain.PaginatedResponse[[]domain.ArchiveEntry], error) {
|
||||||
|
res, err := s.repository.List(ctx, startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entities := make([]domain.ArchiveEntry, len(*res))
|
||||||
|
|
||||||
|
for i, model := range *res {
|
||||||
|
entities[i] = domain.ArchiveEntry{
|
||||||
|
Id: model.Id,
|
||||||
|
Title: model.Title,
|
||||||
|
Path: model.Path,
|
||||||
|
Thumbnail: model.Thumbnail,
|
||||||
|
Source: model.Source,
|
||||||
|
Metadata: model.Metadata,
|
||||||
|
CreatedAt: model.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
first int64
|
||||||
|
next int64
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(entities) > 0 {
|
||||||
|
first, err = s.repository.GetCursor(ctx, entities[0].Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err = s.repository.GetCursor(ctx, entities[len(entities)-1].Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.PaginatedResponse[[]domain.ArchiveEntry]{
|
||||||
|
First: first,
|
||||||
|
Next: next,
|
||||||
|
Data: entities,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCursor implements domain.Service.
|
||||||
|
func (s *Service) GetCursor(ctx context.Context, id string) (int64, error) {
|
||||||
|
return s.repository.GetCursor(ctx, id)
|
||||||
|
}
|
||||||
58
server/archive/utils.go
Normal file
58
server/archive/utils.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Perform a search on the archive.txt file an determines if a download
|
||||||
|
// has already be done.
|
||||||
|
func DownloadExists(ctx context.Context, url string) (bool, error) {
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
config.Instance().DownloaderPath,
|
||||||
|
"--print",
|
||||||
|
"%(extractor)s %(id)s",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extractorAndURL := bytes.Trim(stdout, "\n")
|
||||||
|
|
||||||
|
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "archive.txt"))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer fd.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(fd)
|
||||||
|
|
||||||
|
// search linearly for lower memory usage...
|
||||||
|
// the a pre-sorted with hashed values version of the archive.txt file can be loaded in memory
|
||||||
|
// and perform a binary search on it.
|
||||||
|
for scanner.Scan() {
|
||||||
|
if bytes.Equal(scanner.Bytes(), extractorAndURL) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// data, err := io.ReadAll(fd)
|
||||||
|
// if err != nil {
|
||||||
|
// return false, err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// slices.BinarySearchFunc(data, extractorAndURL, func(a []byte, b []byte) int {
|
||||||
|
// return hash(a).Compare(hash(b))
|
||||||
|
// })
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
42
server/archiver/archiver.go
Normal file
42
server/archiver/archiver.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package archiver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
evbus "github.com/asaskevich/EventBus"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const QueueName = "process:archive"
|
||||||
|
|
||||||
|
var (
|
||||||
|
eventBus = evbus.New()
|
||||||
|
archiveService archive.Service
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message = archive.Entity
|
||||||
|
|
||||||
|
func Register(db *sql.DB) {
|
||||||
|
_, s := archive.Container(db)
|
||||||
|
archiveService = s
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
eventBus.Subscribe(QueueName, func(m *Message) {
|
||||||
|
slog.Info(
|
||||||
|
"archiving completed download",
|
||||||
|
slog.String("title", m.Title),
|
||||||
|
slog.String("source", m.Source),
|
||||||
|
)
|
||||||
|
archiveService.Archive(context.Background(), m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Publish(m *Message) {
|
||||||
|
if config.Instance().AutoArchive {
|
||||||
|
eventBus.Publish(QueueName, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
server/common/types.go
Normal file
18
server/common/types.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Used to deser the yt-dlp -J output
|
||||||
|
type DownloadInfo struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
Size int32 `json:"filesize_approx"`
|
||||||
|
VCodec string `json:"vcodec"`
|
||||||
|
ACodec string `json:"acodec"`
|
||||||
|
Extension string `json:"ext"`
|
||||||
|
OriginalURL string `json:"original_url"`
|
||||||
|
FileName string `json:"filename"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -4,31 +4,39 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
LogPath string `yaml:"log_path"`
|
LogPath string `yaml:"log_path"`
|
||||||
EnableFileLogging bool `yaml:"enable_file_logging"`
|
EnableFileLogging bool `yaml:"enable_file_logging"`
|
||||||
BaseURL string `yaml:"base_url"`
|
BaseURL string `yaml:"base_url"`
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
DownloadPath string `yaml:"downloadPath"`
|
DownloadPath string `yaml:"downloadPath"`
|
||||||
DownloaderPath string `yaml:"downloaderPath"`
|
DownloaderPath string `yaml:"downloaderPath"`
|
||||||
RequireAuth bool `yaml:"require_auth"`
|
RequireAuth bool `yaml:"require_auth"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
QueueSize int `yaml:"queue_size"`
|
QueueSize int `yaml:"queue_size"`
|
||||||
LocalDatabasePath string `yaml:"local_database_path"`
|
LocalDatabasePath string `yaml:"local_database_path"`
|
||||||
SessionFilePath string `yaml:"session_file_path"`
|
SessionFilePath string `yaml:"session_file_path"`
|
||||||
path string // private
|
path string // private
|
||||||
UseOpenId bool `yaml:"use_openid"`
|
UseOpenId bool `yaml:"use_openid"`
|
||||||
OpenIdProviderURL string `yaml:"openid_provider_url"`
|
OpenIdProviderURL string `yaml:"openid_provider_url"`
|
||||||
OpenIdClientId string `yaml:"openid_client_id"`
|
OpenIdClientId string `yaml:"openid_client_id"`
|
||||||
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
||||||
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
||||||
FrontendPath string `yaml:"frontend_path"`
|
OpenIdEmailWhitelist []string `yaml:"openid_email_whitelist"`
|
||||||
|
FrontendPath string `yaml:"frontend_path"`
|
||||||
|
AutoArchive bool `yaml:"auto_archive"`
|
||||||
|
Twitch struct {
|
||||||
|
ClientId string `yaml:"client_id"`
|
||||||
|
ClientSecret string `yaml:"client_secret"`
|
||||||
|
CheckInterval time.Duration `yaml:"check_interval"`
|
||||||
|
} `yaml:"twitch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -40,6 +48,7 @@ func Instance() *Config {
|
|||||||
if instance == nil {
|
if instance == nil {
|
||||||
instanceOnce.Do(func() {
|
instanceOnce.Do(func() {
|
||||||
instance = &Config{}
|
instance = &Config{}
|
||||||
|
instance.Twitch.CheckInterval = time.Minute * 5
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return instance
|
return instance
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock")
|
var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock")
|
||||||
@@ -34,6 +34,33 @@ func Migrate(ctx context.Context, db *sql.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`CREATE TABLE IF NOT EXISTS archive (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
path VARCHAR(255) NOT NULL,
|
||||||
|
thumbnail TEXT,
|
||||||
|
source VARCHAR(255),
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME
|
||||||
|
)`,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
url VARCHAR(2048) UNIQUE NOT NULL,
|
||||||
|
params TEXT NOT NULL,
|
||||||
|
cron TEXT
|
||||||
|
)`,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if lockFileExists() {
|
if lockFileExists() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handlers
|
package filebrowser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
@@ -17,8 +17,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseURL(url string) (*Metadata, error) {
|
func ParseURL(url string) (*Metadata, error) {
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ type Format struct {
|
|||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
VCodec string `json:"vcodec"`
|
VCodec string `json:"vcodec"`
|
||||||
ACodec string `json:"acodec"`
|
ACodec string `json:"acodec"`
|
||||||
Size float32 `json:"filesize_approx"`
|
Size float64 `json:"filesize_approx"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"container/heap"
|
"container/heap"
|
||||||
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoadBalancer struct {
|
type LoadBalancer struct {
|
||||||
@@ -9,7 +10,29 @@ type LoadBalancer struct {
|
|||||||
done chan *Worker
|
done chan *Worker
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *LoadBalancer) Balance(work chan Process) {
|
func NewLoadBalancer(numWorker int) *LoadBalancer {
|
||||||
|
var pool Pool
|
||||||
|
|
||||||
|
doneChan := make(chan *Worker)
|
||||||
|
|
||||||
|
for i := range numWorker {
|
||||||
|
w := &Worker{
|
||||||
|
requests: make(chan *Process, 1),
|
||||||
|
index: i,
|
||||||
|
}
|
||||||
|
go w.Work(doneChan)
|
||||||
|
pool = append(pool, w)
|
||||||
|
|
||||||
|
slog.Info("spawned worker", slog.Int("index", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LoadBalancer{
|
||||||
|
pool: pool,
|
||||||
|
done: doneChan,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *LoadBalancer) Balance(work chan *Process) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case req := <-work:
|
case req := <-work:
|
||||||
@@ -20,7 +43,7 @@ func (b *LoadBalancer) Balance(work chan Process) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *LoadBalancer) dispatch(req Process) {
|
func (b *LoadBalancer) dispatch(req *Process) {
|
||||||
w := heap.Pop(&b.pool).(*Worker)
|
w := heap.Pop(&b.pool).(*Worker)
|
||||||
w.requests <- req
|
w.requests <- req
|
||||||
w.pending++
|
w.pending++
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||||
|
)
|
||||||
|
|
||||||
// Used to unmarshall yt-dlp progress
|
// Used to unmarshall yt-dlp progress
|
||||||
type ProgressTemplate struct {
|
type ProgressTemplate struct {
|
||||||
Percentage string `json:"percentage"`
|
Percentage string `json:"percentage"`
|
||||||
Speed float32 `json:"speed"`
|
Speed float64 `json:"speed"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
Eta float32 `json:"eta"`
|
Eta float64 `json:"eta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostprocessTemplate struct {
|
type PostprocessTemplate struct {
|
||||||
@@ -25,33 +27,18 @@ type DownloadOutput struct {
|
|||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
Status int `json:"process_status"`
|
Status int `json:"process_status"`
|
||||||
Percentage string `json:"percentage"`
|
Percentage string `json:"percentage"`
|
||||||
Speed float32 `json:"speed"`
|
Speed float64 `json:"speed"`
|
||||||
ETA float32 `json:"eta"`
|
ETA float64 `json:"eta"`
|
||||||
}
|
|
||||||
|
|
||||||
// Used to deser the yt-dlp -J output
|
|
||||||
type DownloadInfo struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Thumbnail string `json:"thumbnail"`
|
|
||||||
Resolution string `json:"resolution"`
|
|
||||||
Size int32 `json:"filesize_approx"`
|
|
||||||
VCodec string `json:"vcodec"`
|
|
||||||
ACodec string `json:"acodec"`
|
|
||||||
Extension string `json:"ext"`
|
|
||||||
OriginalURL string `json:"original_url"`
|
|
||||||
FileName string `json:"filename"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// struct representing the response sent to the client
|
// struct representing the response sent to the client
|
||||||
// as JSON-RPC result field
|
// as JSON-RPC result field
|
||||||
type ProcessResponse struct {
|
type ProcessResponse struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Progress DownloadProgress `json:"progress"`
|
Progress DownloadProgress `json:"progress"`
|
||||||
Info DownloadInfo `json:"info"`
|
Info common.DownloadInfo `json:"info"`
|
||||||
Output DownloadOutput `json:"output"`
|
Output DownloadOutput `json:"output"`
|
||||||
Params []string `json:"params"`
|
Params []string `json:"params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// struct representing the current status of the memoryDB
|
// struct representing the current status of the memoryDB
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -141,26 +141,13 @@ func (l *LiveStream) monitorStartTime(r io.Reader) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRIES = 5
|
scanner.Scan()
|
||||||
/*
|
|
||||||
if it's waiting a livestream the 5th line will indicate the time to live
|
|
||||||
its a dumb and not robust method.
|
|
||||||
|
|
||||||
example:
|
for !strings.Contains(scanner.Text(), "Waiting for") {
|
||||||
[youtube] Extracting URL: https://www.youtube.com/watch?v=IQVbGfVVjgY
|
|
||||||
[youtube] IQVbGfVVjgY: Downloading webpage
|
|
||||||
[youtube] IQVbGfVVjgY: Downloading ios player API JSON
|
|
||||||
[youtube] IQVbGfVVjgY: Downloading web creator player API JSON
|
|
||||||
WARNING: [youtube] This live event will begin in 27 minutes. <- STDERR, ignore
|
|
||||||
[wait] Waiting for 00:27:15 - Press Ctrl+C to try now <- 5th line
|
|
||||||
*/
|
|
||||||
for range TRIES {
|
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
|
|
||||||
if strings.Contains(scanner.Text(), "Waiting for") {
|
|
||||||
waitTimeScanner()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitTimeScanner()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *LiveStream) WaitTime() <-chan time.Duration {
|
func (l *LiveStream) WaitTime() <-chan time.Duration {
|
||||||
|
|||||||
@@ -4,20 +4,22 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTest() {
|
func setupTest() {
|
||||||
config.Instance().DownloaderPath = "yt-dlp"
|
config.Instance().DownloaderPath = "build/yt-dlp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const URL = "https://www.youtube.com/watch?v=pwoAyLGOysU"
|
||||||
|
|
||||||
func TestLivestream(t *testing.T) {
|
func TestLivestream(t *testing.T) {
|
||||||
setupTest()
|
setupTest()
|
||||||
|
|
||||||
done := make(chan *LiveStream)
|
done := make(chan *LiveStream)
|
||||||
|
|
||||||
ls := New("https://www.youtube.com/watch?v=LSm1daKezcE", done, &internal.MessageQueue{}, &internal.MemoryDB{})
|
ls := New(URL, done, &internal.MessageQueue{}, &internal.MemoryDB{})
|
||||||
go ls.Start()
|
go ls.Start()
|
||||||
|
|
||||||
time.AfterFunc(time.Second*20, func() {
|
time.AfterFunc(time.Second*20, func() {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Monitor struct {
|
type Monitor struct {
|
||||||
@@ -76,7 +76,7 @@ func (m *Monitor) Status() LiveStreamStatus {
|
|||||||
// Persist the monitor current state to a file.
|
// Persist the monitor current state to a file.
|
||||||
// The file is located in the configured config directory
|
// The file is located in the configured config directory
|
||||||
func (m *Monitor) Persist() error {
|
func (m *Monitor) Persist() error {
|
||||||
fd, err := os.Create(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
|
fd, err := os.Create(filepath.Join(config.Instance().SessionFilePath, "livestreams.dat"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ func (m *Monitor) Persist() error {
|
|||||||
|
|
||||||
// Restore a saved state and resume the monitored livestreams
|
// Restore a saved state and resume the monitored livestreams
|
||||||
func (m *Monitor) Restore() error {
|
func (m *Monitor) Restore() error {
|
||||||
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
|
fd, err := os.Open(filepath.Join(config.Instance().SessionFilePath, "livestreams.dat"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var memDbEvents = make(chan *Process)
|
||||||
|
|
||||||
// In-Memory Thread-Safe Key-Value Storage with optional persistence
|
// In-Memory Thread-Safe Key-Value Storage with optional persistence
|
||||||
type MemoryDB struct {
|
type MemoryDB struct {
|
||||||
table map[string]*Process
|
table map[string]*Process
|
||||||
@@ -144,3 +147,12 @@ func (m *MemoryDB) Restore(mq *MessageQueue) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MemoryDB) EventListener() {
|
||||||
|
for p := range memDbEvents {
|
||||||
|
if p.AutoRemove {
|
||||||
|
slog.Info("compacting MemoryDB", slog.String("id", p.Id))
|
||||||
|
m.Delete(p.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
evbus "github.com/asaskevich/EventBus"
|
evbus "github.com/asaskevich/EventBus"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
"golang.org/x/sync/semaphore"
|
"golang.org/x/sync/semaphore"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,20 +9,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/playlist"
|
||||||
)
|
)
|
||||||
|
|
||||||
type metadata struct {
|
|
||||||
Entries []DownloadInfo `json:"entries"`
|
|
||||||
Count int `json:"playlist_count"`
|
|
||||||
PlaylistTitle string `json:"title"`
|
|
||||||
Type string `json:"_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||||
|
params := append(req.Params, "--flat-playlist", "-J")
|
||||||
|
urlWithParams := append([]string{req.URL}, params...)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
downloader = config.Instance().DownloaderPath
|
downloader = config.Instance().DownloaderPath
|
||||||
cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J")
|
cmd = exec.Command(downloader, urlWithParams...)
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
@@ -30,7 +28,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var m metadata
|
var m playlist.Metadata
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -52,13 +50,21 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
|||||||
return errors.New("probably not a valid URL")
|
return errors.New("probably not a valid URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.Type == "playlist" {
|
if m.IsPlaylist() {
|
||||||
entries := slices.CompactFunc(slices.Compact(m.Entries), func(a DownloadInfo, b DownloadInfo) bool {
|
entries := slices.CompactFunc(slices.Compact(m.Entries), func(a common.DownloadInfo, b common.DownloadInfo) bool {
|
||||||
return a.URL == b.URL
|
return a.URL == b.URL
|
||||||
})
|
})
|
||||||
|
|
||||||
|
entries = slices.DeleteFunc(entries, func(e common.DownloadInfo) bool {
|
||||||
|
return strings.Contains(e.URL, "list=")
|
||||||
|
})
|
||||||
|
|
||||||
slog.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries)))
|
slog.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries)))
|
||||||
|
|
||||||
|
if err := playlist.ApplyModifiers(&entries, req.Params); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for i, meta := range entries {
|
for i, meta := range entries {
|
||||||
// detect playlist title from metadata since each playlist entry will be
|
// detect playlist title from metadata since each playlist entry will be
|
||||||
// treated as an individual download
|
// treated as an individual download
|
||||||
@@ -82,11 +88,13 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
|||||||
|
|
||||||
proc.Info.URL = meta.URL
|
proc.Info.URL = meta.URL
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
|
|
||||||
db.Set(proc)
|
db.Set(proc)
|
||||||
mq.Publish(proc)
|
mq.Publish(proc)
|
||||||
|
|
||||||
|
proc.Info.CreatedAt = meta.CreatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
proc := &Process{
|
proc := &Process{
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
|
// Pool implements heap.Interface interface as a standard priority queue
|
||||||
type Pool []*Worker
|
type Pool []*Worker
|
||||||
|
|
||||||
func (h Pool) Len() int { return len(h) }
|
func (h Pool) Len() int { return len(h) }
|
||||||
func (h Pool) Less(i, j int) bool { return h[i].index < h[j].index }
|
func (h Pool) Less(i, j int) bool { return h[i].pending < h[j].pending }
|
||||||
func (h Pool) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
|
||||||
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) }
|
func (h Pool) Swap(i, j int) {
|
||||||
|
h[i], h[j] = h[j], h[i]
|
||||||
|
h[i].index = i
|
||||||
|
h[j].index = j
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) }
|
||||||
|
|
||||||
func (h *Pool) Pop() any {
|
func (h *Pool) Pop() any {
|
||||||
old := *h
|
old := *h
|
||||||
n := len(old)
|
n := len(old)
|
||||||
x := old[n-1]
|
x := old[n-1]
|
||||||
|
old[n-1] = nil
|
||||||
*h = old[0 : n-1]
|
*h = old[0 : n-1]
|
||||||
return x
|
return x
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const downloadTemplate = `download:
|
const downloadTemplate = `download:
|
||||||
@@ -47,8 +49,9 @@ type Process struct {
|
|||||||
Id string
|
Id string
|
||||||
Url string
|
Url string
|
||||||
Livestream bool
|
Livestream bool
|
||||||
|
AutoRemove bool
|
||||||
Params []string
|
Params []string
|
||||||
Info DownloadInfo
|
Info common.DownloadInfo
|
||||||
Progress DownloadProgress
|
Progress DownloadProgress
|
||||||
Output DownloadOutput
|
Output DownloadOutput
|
||||||
proc *os.Process
|
proc *os.Process
|
||||||
@@ -87,6 +90,7 @@ func (p *Process) Start() {
|
|||||||
buildFilename(&p.Output)
|
buildFilename(&p.Output)
|
||||||
|
|
||||||
templateReplacer := strings.NewReplacer("\n", "", "\t", "", " ", "")
|
templateReplacer := strings.NewReplacer("\n", "", "\t", "", " ", "")
|
||||||
|
|
||||||
baseParams := []string{
|
baseParams := []string{
|
||||||
strings.Split(p.Url, "?list")[0], //no playlist
|
strings.Split(p.Url, "?list")[0], //no playlist
|
||||||
"--newline",
|
"--newline",
|
||||||
@@ -96,6 +100,7 @@ func (p *Process) Start() {
|
|||||||
templateReplacer.Replace(downloadTemplate),
|
templateReplacer.Replace(downloadTemplate),
|
||||||
"--progress-template",
|
"--progress-template",
|
||||||
templateReplacer.Replace(postprocessTemplate),
|
templateReplacer.Replace(postprocessTemplate),
|
||||||
|
"--no-exec",
|
||||||
}
|
}
|
||||||
|
|
||||||
// if user asked to manually override the output path...
|
// if user asked to manually override the output path...
|
||||||
@@ -193,13 +198,12 @@ func (p *Process) parseLogEntry(entry []byte) {
|
|||||||
if err := json.Unmarshal(entry, &postprocess); err == nil {
|
if err := json.Unmarshal(entry, &postprocess); err == nil {
|
||||||
p.Output.SavedFilePath = postprocess.FilePath
|
p.Output.SavedFilePath = postprocess.FilePath
|
||||||
|
|
||||||
slog.Info("postprocess",
|
// slog.Info("postprocess",
|
||||||
slog.String("id", p.getShortId()),
|
// slog.String("id", p.getShortId()),
|
||||||
slog.String("url", p.Url),
|
// slog.String("url", p.Url),
|
||||||
slog.String("filepath", postprocess.FilePath),
|
// slog.String("filepath", postprocess.FilePath),
|
||||||
)
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Process) detectYtDlpErrors(r io.Reader) {
|
func (p *Process) detectYtDlpErrors(r io.Reader) {
|
||||||
@@ -218,6 +222,24 @@ func (p *Process) detectYtDlpErrors(r io.Reader) {
|
|||||||
// Convention: All completed processes has progress -1
|
// Convention: All completed processes has progress -1
|
||||||
// and speed 0 bps.
|
// and speed 0 bps.
|
||||||
func (p *Process) Complete() {
|
func (p *Process) Complete() {
|
||||||
|
// auto archive
|
||||||
|
// TODO: it's not that deterministic :/
|
||||||
|
if p.Progress.Percentage == "" && p.Progress.Speed == 0 {
|
||||||
|
var serializedMetadata bytes.Buffer
|
||||||
|
|
||||||
|
json.NewEncoder(&serializedMetadata).Encode(p.Info)
|
||||||
|
|
||||||
|
archiver.Publish(&archiver.Message{
|
||||||
|
Id: p.Id,
|
||||||
|
Path: p.Output.SavedFilePath,
|
||||||
|
Title: p.Info.Title,
|
||||||
|
Thumbnail: p.Info.Thumbnail,
|
||||||
|
Source: p.Url,
|
||||||
|
Metadata: serializedMetadata.String(),
|
||||||
|
CreatedAt: p.Info.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
p.Progress = DownloadProgress{
|
p.Progress = DownloadProgress{
|
||||||
Status: StatusCompleted,
|
Status: StatusCompleted,
|
||||||
Percentage: "-1",
|
Percentage: "-1",
|
||||||
@@ -234,6 +256,8 @@ func (p *Process) Complete() {
|
|||||||
slog.String("id", p.getShortId()),
|
slog.String("id", p.getShortId()),
|
||||||
slog.String("url", p.Url),
|
slog.String("url", p.Url),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
memDbEvents <- p
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill a process and remove it from the memory
|
// Kill a process and remove it from the memory
|
||||||
@@ -280,7 +304,7 @@ func (p *Process) GetFileName(o *DownloadOutput) error {
|
|||||||
|
|
||||||
func (p *Process) SetPending() {
|
func (p *Process) SetPending() {
|
||||||
// Since video's title isn't available yet, fill in with the URL.
|
// Since video's title isn't available yet, fill in with the URL.
|
||||||
p.Info = DownloadInfo{
|
p.Info = common.DownloadInfo{
|
||||||
URL: p.Url,
|
URL: p.Url,
|
||||||
Title: p.Url,
|
Title: p.Url,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -312,7 +336,7 @@ func (p *Process) SetMetadata() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
info := DownloadInfo{
|
info := common.DownloadInfo{
|
||||||
URL: p.Url,
|
URL: p.Url,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
type Worker struct {
|
type Worker struct {
|
||||||
requests chan Process // downloads to do
|
requests chan *Process // downloads to do
|
||||||
pending int // downloads pending
|
pending int // downloads pending
|
||||||
index int // index in the heap
|
index int // index in the heap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) Work(done chan *Worker) {
|
func (w *Worker) Work(done chan *Worker) {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/middleware"
|
middlewares "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/middleware"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
|
|||||||
21
server/middleware/utils.go
Normal file
21
server/middleware/utils.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package middlewares
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ApplyAuthenticationByConfig(next http.Handler) http.Handler {
|
||||||
|
handler := next
|
||||||
|
|
||||||
|
if config.Instance().RequireAuth {
|
||||||
|
handler = Authenticated(handler)
|
||||||
|
}
|
||||||
|
if config.Instance().UseOpenId {
|
||||||
|
handler = openid.Middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user