Compare commits

..

12 Commits

Author SHA1 Message Date
13c23303a9 persist app title + code refactoring 2025-02-13 14:02:00 +01:00
LelieL91
983915f8aa Fixed static file location (#263)
* Update EN, IT langs

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

* Fixed files location

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

* Update migrate.go

revert edit

---------

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

Co-authored-by: Marco Piovanello <35533749+marcopiovanello@users.noreply.github.com>
2025-02-06 09:30:21 +01:00
3c21253562 added latest keys to each language file (not translated) 2025-02-05 11:08:15 +01:00
b243c1c958 hotfix for #259 2025-02-05 10:49:15 +01:00
Marco Piovanello
7be5bc7b1f Update README.md 2025-02-05 09:16:05 +01:00
42 changed files with 842 additions and 192 deletions

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ frontend/.yarn/install-state.gz
livestreams.dat livestreams.dat
.vite/deps .vite/deps
archive.txt archive.txt
web_config.yml

View File

@@ -25,6 +25,11 @@ docker pull ghcr.io/marcopiovanello/yt-dlp-web-ui:latest
*Keeps the project alive!* 😃 *Keeps the project alive!* 😃
## Community stuff
Feel free to join :)
[![Discord Banner](https://api.weblutions.com/discord/invite/3Sj9ZZHv/)](https://discord.gg/3Sj9ZZHv)
## Some screeshots ## Some screeshots
![image](https://github.com/user-attachments/assets/fc43a3fb-ecf9-449d-b5cb-5d5635020c00) ![image](https://github.com/user-attachments/assets/fc43a3fb-ecf9-449d-b5cb-5d5635020c00)
![image](https://github.com/user-attachments/assets/3210f6ac-0dd8-403c-b839-3c24ff7d7d00) ![image](https://github.com/user-attachments/assets/3210f6ac-0dd8-403c-b839-3c24ff7d7d00)

View File

@@ -18,11 +18,12 @@
"@mui/icons-material": "^6.2.0", "@mui/icons-material": "^6.2.0",
"@mui/material": "^6.2.0", "@mui/material": "^6.2.0",
"fp-ts": "^2.16.5", "fp-ts": "^2.16.5",
"jotai": "^2.10.3",
"jotai-cache": "^0.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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.3",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -32,6 +32,9 @@ importers:
jotai: jotai:
specifier: ^2.10.3 specifier: ^2.10.3
version: 2.10.3(@types/react@19.0.1)(react@19.0.0) version: 2.10.3(@types/react@19.0.1)(react@19.0.0)
jotai-cache:
specifier: ^0.5.0
version: 0.5.0(jotai@2.10.3(@types/react@19.0.1)(react@19.0.0))
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.0.0 version: 19.0.0
@@ -737,6 +740,11 @@ packages:
is-core-module@2.12.1: is-core-module@2.12.1:
resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==}
jotai-cache@0.5.0:
resolution: {integrity: sha512-29pUuEfSXL7Ba6lxZmiNDARc73TspWzAzCy0jCkk2uEOnFJ6kaUBZTp/AZSwnIsh1ndfUfM9/QpbLU7uJAQL0A==}
peerDependencies:
jotai: '>=2.0.0'
jotai@2.10.3: jotai@2.10.3:
resolution: {integrity: sha512-Nnf4IwrLhNfuz2JOQLI0V/AgwcpxvVy8Ec8PidIIDeRi4KCFpwTFIpHAAcU+yCgnw/oASYElq9UY0YdUUegsSA==} resolution: {integrity: sha512-Nnf4IwrLhNfuz2JOQLI0V/AgwcpxvVy8Ec8PidIIDeRi4KCFpwTFIpHAAcU+yCgnw/oASYElq9UY0YdUUegsSA==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -1512,6 +1520,10 @@ snapshots:
dependencies: dependencies:
has: 1.0.3 has: 1.0.3
jotai-cache@0.5.0(jotai@2.10.3(@types/react@19.0.1)(react@19.0.0)):
dependencies:
jotai: 2.10.3(@types/react@19.0.1)(react@19.0.0)
jotai@2.10.3(@types/react@19.0.1)(react@19.0.0): jotai@2.10.3(@types/react@19.0.1)(react@19.0.0):
optionalDependencies: optionalDependencies:
'@types/react': 19.0.1 '@types/react': 19.0.1

View File

@@ -16,13 +16,13 @@ import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon' import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText' 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 { grey } from '@mui/material/colors' import { grey } from '@mui/material/colors'
import { useAtomValue } from 'jotai' 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'
import AppBar from './components/AppBar' import AppBar from './components/AppBar'
import { AppTitle } from './components/AppTitle'
import Drawer from './components/Drawer' import Drawer from './components/Drawer'
import Footer from './components/Footer' import Footer from './components/Footer'
import Logout from './components/Logout' import Logout from './components/Logout'
@@ -76,15 +76,7 @@ export default function Layout() {
> >
<Menu /> <Menu />
</IconButton> </IconButton>
<Typography <AppTitle />
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
{settings.appTitle}
</Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer variant="permanent" open={open}> <Drawer variant="permanent" open={open}>

View File

@@ -69,3 +69,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -71,3 +71,12 @@ keys:
noFilesFound: 'Keine Dateien gefunden' noFilesFound: 'Keine Dateien gefunden'
tableView: 'Tabellenansicht' tableView: 'Tabellenansicht'
deleteSelected: 'Ausgewählte löschen' deleteSelected: 'Ausgewählte löschen'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -27,11 +27,11 @@ keys:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsibilities) customArgs: Enable custom yt-dlp args (great power = great responsibilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server rpcConnErr: Error while connecting to RPC server
splashText: No active downloads splashText: No active downloads
archiveTitle: Archive archiveTitle: Archive
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window) playlistCheckbox: Download playlist
restartAppMessage: Needs a page reload to take effect restartAppMessage: Needs a page reload to take effect
servedFromReverseProxyCheckbox: Is behind a reverse proxy servedFromReverseProxyCheckbox: Is behind a reverse proxy
urlBase: URL base, for reverse proxy support (subdir), defaults to empty urlBase: URL base, for reverse proxy support (subdir), defaults to empty

View File

@@ -69,3 +69,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -73,3 +73,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -71,3 +71,12 @@ keys:
noFilesFound: 'Nem található fájlok' noFilesFound: 'Nem található fájlok'
tableView: 'Táblázatos Nézet' tableView: 'Táblázatos Nézet'
deleteSelected: 'Kiválasztottak törlése' deleteSelected: 'Kiválasztottak törlése'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -1,8 +1,9 @@
keys: keys:
urlInput: URL Video (uno per linea) urlInput: URL Video (uno per linea)
statusTitle: Stato statusTitle: Stato
startButton: Inizia
statusReady: Pronto statusReady: Pronto
selectFormatButton: Seziona formato
startButton: Inizia
abortAllButton: Termina tutto abortAllButton: Termina tutto
updateBinButton: Aggiorna yt-dlp updateBinButton: Aggiorna yt-dlp
darkThemeButton: Tema scuro darkThemeButton: Tema scuro
@@ -22,51 +23,60 @@ keys:
pathOverrideOption: Abilita sovrascrittura percorso di output pathOverrideOption: Abilita sovrascrittura percorso di output
filenameOverrideOption: Abilita sovrascrittura del nome del file di output filenameOverrideOption: Abilita sovrascrittura del nome del file di output
autoFileExtensionOption: Aggiungi estensione automaticamente autoFileExtensionOption: Aggiungi estensione automaticamente
customFilename: Custom filename (leave blank to use default) customFilename: Nome file personalizzato (lascia vuoto per utilizzare quello predefinito)
customPath: Custom path customPath: Percorso personalizzato
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Abilita argomenti yt-dlp personalizzati (grande potere = grandi responsabilità)
customArgsInput: Custom yt-dlp arguments customArgsInput: Argomenti yt-dlp personalizzati
rpcConnErr: Error nella connessione al server RPC rpcConnErr: Errore nella connessione al server RPC
splashText: Nessun download attivo splashText: Nessun download attivo
archiveTitle: Archivio archiveTitle: Archivio
clipboardAction: URL copiato negli appunti clipboardAction: URL copiato negli appunti
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro) playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto restartAppMessage: La finestra deve essere ricaricata affinché abbia effetto
servedFromReverseProxyCheckbox: Is behind a reverse proxy servedFromReverseProxyCheckbox: È dietro un reverse proxy
urlBase: base URL, per supporto a reverse proxy (subdir), default vuoto
newDownloadButton: Nuovo download newDownloadButton: Nuovo download
homeButtonLabel: Home homeButtonLabel: Home
archiveButtonLabel: Archive archiveButtonLabel: Archivio
settingsButtonLabel: Settings settingsButtonLabel: Impostazioni
rpcAuthenticationLabel: RPC authentication rpcAuthenticationLabel: Autenticazione RPC
themeTogglerLabel: Theme toggler themeTogglerLabel: Selettore Tema
loadingLabel: Loading... loadingLabel: Caricamento...
appTitle: Titolo applicazione appTitle: Titolo applicazione
savedTemplates: Template salvati savedTemplates: Modelli salvati
templatesEditor: Editor template templatesEditor: Editor modelli
templatesEditorNameLabel: Nome template templatesEditorNameLabel: Nome modello
templatesEditorContentLabel: Contentunto template templatesEditorContentLabel: Contenuto del modello
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Scaricare i file in un archivio 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: base URL, per supporto a reverse proxy (subdir), default vuoto
rpcPollingTimeTitle: Intervallo di polling RPC rpcPollingTimeTitle: Intervallo di polling RPC
rpcPollingTimeDescription: Un intervallo più corto implica un maggior utilizzo di CPU (lato client e server) rpcPollingTimeDescription: Un intervallo più corto implica un maggior utilizzo di CPU (lato client e server)
generalDownloadSettings: 'General Download Settings' templatesReloadInfo: Per registrare un nuovo modello potrebbe essere necessario ricaricare la pagina.
deleteCookies: Delete Cookies livestreamURLInput: Livestream URL
noFilesFound: 'No Files Found' livestreamStatusWaiting: Attesa inizio
tableView: 'Table View' livestreamStatusDownloading: Downloading
deleteSelected: 'Delete selected' livestreamStatusCompleted: Completato
livestreamStatusErrored: Errore
livestreamStatusUnknown: Sconosciuto
livestreamNoMonitoring: Nessun livestream monitorato
livestreamDownloadInfo: |
Questo monitorerà il livestream ancora da avviare. Ogni processo verrà eseguito con --wait-for-video 10.
Se viene fornito un livestream già avviato, questo verrà comunque scaricato, ma il suo progresso non verrà monitorato.
Una volta avviato, il livestream verrà migrato nella pagina dei download.
livestreamExperimentalWarning: Questa funzione è ancora sperimentale. Qualcosa potrebbe rompersi!
accentSelect: 'Accent'
generalDownloadSettings: 'Impostazioni generali di download'
deleteCookies: Elimina Cookies
noFilesFound: 'Nessun file trovato'
tableView: 'Vista Tabella'
deleteSelected: 'Elimina selezionati'
subscriptionsButtonLabel: 'Abbonamenti'
subscriptionsEmptyLabel: 'Nessuna iscrizione'
subscriptionsURLInput: 'URL Canale'
subscriptionsInfo: |
Iscrive a un canale definito. Verrà scaricato solo l'ultimo video.
Il lavoro di monitoraggio sarà programmato/attivato da un'espressione cron definita (se lasciata vuota, l'impostazione predefinita è ogni 5 minuti).
cronExpressionLabel: 'Espressione Cron'
editButtonLabel: 'Modifica'
newSubscriptionButton: Nuova iscrizione

View File

@@ -70,3 +70,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -69,3 +69,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -69,3 +69,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -71,3 +71,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -69,3 +69,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -71,3 +71,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -69,3 +69,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -71,3 +71,12 @@ keys:
noFilesFound: 'No Files Found' noFilesFound: 'No Files Found'
tableView: 'Table View' tableView: 'Table View'
deleteSelected: 'Delete selected' deleteSelected: 'Delete selected'
subscriptionsButtonLabel: 'Subscriptions'
subscriptionsEmptyLabel: 'No subscriptions'
subscriptionsURLInput: 'Channel URL'
subscriptionsInfo: |
Subscribes to a defined channel. Only the last video will be downloaded.
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit'
newSubscriptionButton: New subscription

View File

@@ -1,12 +1,12 @@
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 { atomWithCache } from 'jotai-cache'
import { atomWithStorage } from 'jotai/utils' 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'
export const cookiesTemplateState = atom<Promise<string>>(async (get) => export const cookiesTemplateState = atomWithCache<Promise<string>>(async (get) =>
await get(serverSideCookiesState) await get(serverSideCookiesState)
? '--cookies=cookies.txt' ? '--cookies=cookies.txt'
: '' : ''
@@ -22,7 +22,7 @@ export const filenameTemplateState = atomWithStorage(
localStorage.getItem('lastFilenameTemplate') ?? '' localStorage.getItem('lastFilenameTemplate') ?? ''
) )
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => { export const savedTemplatesState = atomWithCache<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()
@@ -30,5 +30,4 @@ export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) =
either, either,
getOrElse(() => new Array<CustomTemplate>()) getOrElse(() => new Array<CustomTemplate>())
) )
} })
)

View File

@@ -1,9 +1,9 @@
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { matchW } from 'fp-ts/lib/TaskEither' import { matchW } from 'fp-ts/lib/TaskEither'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { prefersDarkMode } from '../utils' import { prefersDarkMode } from '../utils'
import { atomWithStorage } from 'jotai/utils'
import { atom } from 'jotai'
export const languages = [ export const languages = [
'catalan', 'catalan',
@@ -121,11 +121,15 @@ 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)}` return `${get(serverAddressState)}:${get(serverPortState)}`
.replaceAll('"', '')
}) })
export const serverURL = atom((get) => export const serverURL = atom((get) =>
@@ -135,14 +139,12 @@ 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` return `${proto}//${get(serverAddressAndPortState)}/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` return `${proto}//${get(serverAddressAndPortState)}/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`),
@@ -180,5 +182,4 @@ export const settingsState = atom<SettingsState>((get) => ({
listView: get(listViewState), listView: get(listViewState),
servedFromReverseProxy: get(servedFromReverseProxyState), servedFromReverseProxy: get(servedFromReverseProxyState),
appTitle: get(appTitleState) appTitle: get(appTitleState)
}) }))
)

View File

@@ -0,0 +1,33 @@
import { Typography } from '@mui/material'
import { useAtom } from 'jotai'
import { useEffect } from 'react'
import { appTitleState } from '../atoms/settings'
import useFetch from '../hooks/useFetch'
export const AppTitle: React.FC = () => {
const [appTitle, setAppTitle] = useAtom(appTitleState)
const { data } = useFetch<{ title: string }>('/webconfig')
useEffect(() => {
if (data?.title) {
setAppTitle(
data.title.startsWith('"')
? data.title.substring(1, data.title.length - 1)
: data.title
)
}
}, [data])
return (
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
{appTitle.startsWith('"') ? appTitle.substring(1, appTitle.length - 1) : appTitle}
</Typography>
)
}

View File

@@ -110,7 +110,7 @@ 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 = `${customArgsState} ${cookies}` const downloadTemplate = `${customArgs} ${cookies}`
.replace(/ +/g, ' ') .replace(/ +/g, ' ')
.trim() .trim()

View File

@@ -15,7 +15,7 @@ import {
Typography Typography
} from '@mui/material' } from '@mui/material'
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/TaskEither'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { forwardRef, startTransition, useState } from 'react' import { forwardRef, startTransition, useState } from 'react'
@@ -52,21 +52,16 @@ const SubscriptionsDialog: React.FC<Props> = ({ open, onClose }) => {
const baseURL = useAtomValue(serverURL) const baseURL = useAtomValue(serverURL)
const submit = async (sub: Omit<Subscription, 'id'>) => { const submit = async (sub: Omit<Subscription, 'id'>) => pipe(
const task = ffetch<void>(`${baseURL}/subscriptions`, { ffetch<void>(`${baseURL}/subscriptions`, {
method: 'POST', method: 'POST',
body: JSON.stringify(sub) body: JSON.stringify(sub)
}) }),
const either = await task()
pipe(
either,
matchW( matchW(
(l) => pushMessage(l, 'error'), (l) => pushMessage(l, 'error'),
(_) => onClose() (_) => onClose()
) )
) )()
}
return ( return (
<Dialog <Dialog

View File

@@ -6,6 +6,12 @@ import { serverURL } from '../atoms/settings'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { useToast } from './toast' import { useToast } from './toast'
/**
* Wrapper hook for ffetch. Handles data retrieval and cancellation signals.
* If R type is set to void it doesn't perform deserialization.
* @param resource path of the resource. serverURL is prepended
* @returns JSON decoded value, eventual error and refetcher as an object to destruct.
*/
const useFetch = <R>(resource: string) => { const useFetch = <R>(resource: string) => {
const base = useAtomValue(serverURL) const base = useAtomValue(serverURL)
@@ -26,7 +32,10 @@ const useFetch = <R>(resource: string) => {
)() )()
useEffect(() => { useEffect(() => {
const controller = new AbortController()
fetcher() fetcher()
return () => controller.abort()
}, []) }, [])
return { data, error, fetcher } return { data, error, fetcher }

View File

@@ -1,6 +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'
async function fetcher<T>(url: string, opt?: RequestInit): Promise<T> { async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise<string> {
const jwt = localStorage.getItem('token') const jwt = localStorage.getItem('token')
if (opt && !opt.headers) { if (opt && !opt.headers) {
@@ -14,17 +17,25 @@ async function fetcher<T>(url: string, opt?: RequestInit): Promise<T> {
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) => tryCatch( export const ffetch = <T>(url: string, opt?: RequestInit, controller?: AbortController) => tryCatch(
() => fetcher<T>(url, opt), async () => pipe(
await fetcher(url, opt, controller),
J.parse,
E.match(
(l) => l as T,
(r) => r as T
)
),
(e) => `error while fetching: ${e}` (e) => `error while fetching: ${e}`
) )

View File

@@ -17,7 +17,9 @@ import {
Typography, Typography,
capitalize capitalize
} from '@mui/material' } from '@mui/material'
import { useAtom } from 'jotai' import { pipe } from 'fp-ts/lib/function'
import { matchW } from 'fp-ts/lib/TaskEither'
import { useAtom, useAtomValue } from 'jotai'
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import { import {
Subject, Subject,
@@ -34,9 +36,9 @@ import {
accentState, accentState,
accents, accents,
appTitleState, appTitleState,
autoFileExtensionState,
enableCustomArgsState, enableCustomArgsState,
fileRenamingState, fileRenamingState,
autoFileExtensionState,
formatSelectionState, formatSelectionState,
languageState, languageState,
languages, languages,
@@ -45,12 +47,14 @@ import {
servedFromReverseProxySubDirState, servedFromReverseProxySubDirState,
serverAddressState, serverAddressState,
serverPortState, serverPortState,
serverURL,
themeState themeState
} from '../atoms/settings' } from '../atoms/settings'
import CookiesTextField from '../components/CookiesTextField' 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 { ffetch } from '../lib/httpClient'
import Translator from '../lib/i18n' import Translator from '../lib/i18n'
import { validateDomain, validateIP } from '../utils' import { validateDomain, validateIP } from '../utils'
@@ -70,7 +74,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 [accent, setAccent] = useAtom(accentState)
const [theme, setTheme] = useAtom(themeState) const [theme, setTheme] = useAtom(themeState)
@@ -81,7 +85,11 @@ export default function Settings() {
const { pushMessage } = useToast() const { pushMessage } = useToast()
// TODO: change name
const derivedServerURL = useAtomValue(serverURL)
const baseURL$ = useMemo(() => new Subject<string>(), []) const baseURL$ = useMemo(() => new Subject<string>(), [])
const appTitle$ = useMemo(() => new Subject<string>(), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), []) const serverPort$ = useMemo(() => new Subject<string>(), [])
@@ -134,6 +142,25 @@ export default function Settings() {
return () => sub.unsubscribe() return () => sub.unsubscribe()
}, []) }, [])
// TODO: refactor out of component. maybe use withAtomEffect from jotai/effect package.
useEffect(() => {
const sub = appTitle$
.pipe(debounceTime(500))
.subscribe(title => {
pipe(
ffetch(`${derivedServerURL}/webconfig/title`, {
method: 'PATCH',
body: JSON.stringify(title)
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => setAppTitle(title)
)
)()
})
return () => sub.unsubscribe()
}, [])
/** /**
* Language toggler handler * Language toggler handler
*/ */
@@ -194,7 +221,7 @@ export default function Settings() {
fullWidth fullWidth
label={i18n.t('appTitle')} label={i18n.t('appTitle')}
defaultValue={appTitle} defaultValue={appTitle}
onChange={(e) => setApptitle(e.currentTarget.value)} onChange={(e) => appTitle$.next(e.target.value)}
error={appTitle === ''} error={appTitle === ''}
/> />
</Grid> </Grid>
@@ -218,7 +245,7 @@ export default function Settings() {
{ value: 500, label: '500 ms' }, { value: 500, label: '500 ms' },
{ value: 750, label: '750 ms' }, { value: 750, label: '750 ms' },
{ value: 1000, label: '1000 ms' }, { value: 1000, label: '1000 ms' },
{ value: 2000, label: '2000 ms' }, { value: 2000, label: '2 s' },
]} ]}
onChange={(_, value) => typeof value === 'number' onChange={(_, value) => typeof value === 'number'
? setPollingTime(value) ? setPollingTime(value)

58
server/archive/utils.go Normal file
View 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
}

18
server/common/types.go Normal file
View 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"`
}

View File

@@ -0,0 +1,104 @@
package configurator
import (
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"gopkg.in/yaml.v3"
)
// A singleton holding configuration of the frontend component
// with optional persistence on a file.
type AppConfig struct {
Title string `yaml:"title" json:"title"`
BaseURL string `yaml:"base_url" json:"base_url"`
Language string `yaml:"language" json:"language"`
RPCPollingTime int `yaml:"rpc_polling_time" json:"rpc_polling_time"`
}
type Configurator struct {
mu sync.RWMutex
Config AppConfig
}
var (
instance *Configurator
instanceOnce sync.Once
)
func Instance() *Configurator {
instanceOnce.Do(func() {
if instance == nil {
instance = &Configurator{}
// TODO: move out of initialization
err := instance.Load()
if err != nil {
slog.Error("failed initializating configurator", slog.Any("err", err))
}
}
})
return instance
}
func (c *Configurator) Load() error {
fd, err := getConfigurationFile()
if err != nil {
return err
}
defer fd.Close()
if err := yaml.NewDecoder(fd).Decode(&c.Config); err != nil {
return err
}
return nil
}
func (c *Configurator) Persist() error {
fd, err := getConfigurationFile()
if err != nil {
return err
}
defer fd.Close()
if err := yaml.NewEncoder(fd).Encode(c.Config); err != nil {
return err
}
return nil
}
func (c *Configurator) setAppConfig(ac *AppConfig) {
c.mu.RLock()
defer c.mu.RUnlock()
// TODO: better validaitons
if ac.BaseURL != "" {
c.Config.BaseURL = ac.BaseURL
}
if ac.Language != "" {
c.Config.Language = ac.Language
}
if ac.Title != "" {
c.Config.Title = ac.Title
}
if ac.RPCPollingTime >= 250 && ac.RPCPollingTime <= 2000 {
c.Config.RPCPollingTime = ac.RPCPollingTime
}
}
func getConfigurationFile() (*os.File, error) {
fd, err := os.OpenFile(
filepath.Join(config.Instance().Dir(), "web_config.yml"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644,
)
if err != nil {
return nil, err
}
return fd, nil
}

View File

@@ -0,0 +1,103 @@
package configurator
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
)
// App configurator REST handlers
func GetConfig(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(Instance().Config); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func SetConfig(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
var req AppConfig
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Instance().setAppConfig(&req)
if err := Instance().Persist(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode("ok")
}
func setAppTitle(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
var req string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
editField(w, func(c *AppConfig) {
if req != "" {
c.Title = req
}
})
json.NewEncoder(w).Encode("ok")
}
func setBaseURL(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
var req string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
editField(w, func(c *AppConfig) {
if req != "" {
c.BaseURL = req
}
})
json.NewEncoder(w).Encode("ok")
}
func editField(w http.ResponseWriter, editFunc func(c *AppConfig)) {
editFunc(&Instance().Config)
if err := Instance().Persist(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func ApplyRouter() func(r chi.Router) {
return func(r chi.Router) {
r.Get("/", GetConfig)
r.Post("/", SetConfig)
r.Patch("/title", setAppTitle)
r.Patch("/baseURL", setBaseURL)
}
}

View File

@@ -1,6 +1,8 @@
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 {
@@ -29,27 +31,12 @@ type DownloadProgress struct {
ETA float64 `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"`
} }

View File

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

View File

@@ -9,20 +9,18 @@ import (
"strings" "strings"
"time" "time"
"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/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
@@ -53,12 +51,20 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
} }
if m.Type == "playlist" { if m.Type == "playlist" {
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{

View File

@@ -19,6 +19,7 @@ import (
"time" "time"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver" "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" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
) )
@@ -50,7 +51,7 @@ type Process struct {
Livestream bool Livestream bool
AutoRemove 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
@@ -302,7 +303,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(),
@@ -334,7 +335,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(),
} }

View File

@@ -0,0 +1,86 @@
package playlist
import (
"slices"
"strconv"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
)
/*
Applicable modifiers
full | short | description
---------------------------------------------------------------------------------
--playlist-start NUMBER | -I NUMBER: | discard first N entries
--playlist-end NUMBER | -I :NUMBER | discard last N entries
--playlist-reverse | -I ::-1 | self explanatory
--max-downloads NUMBER | | stops after N completed downloads
*/
func ApplyModifiers(entries *[]common.DownloadInfo, args []string) error {
for i, modifier := range args {
switch modifier {
case "--playlist-start":
return playlistStart(i, modifier, args, entries)
case "--playlist-end":
return playlistEnd(i, modifier, args, entries)
case "--max-downloads":
return maxDownloads(i, modifier, args, entries)
case "--playlist-reverse":
slices.Reverse(*entries)
return nil
}
}
return nil
}
func playlistStart(i int, modifier string, args []string, entries *[]common.DownloadInfo) error {
if !guard(i, len(modifier)) {
return nil
}
n, err := strconv.Atoi(args[i+1])
if err != nil {
return err
}
*entries = (*entries)[n:]
return nil
}
func playlistEnd(i int, modifier string, args []string, entries *[]common.DownloadInfo) error {
if !guard(i, len(modifier)) {
return nil
}
n, err := strconv.Atoi(args[i+1])
if err != nil {
return err
}
*entries = (*entries)[:n]
return nil
}
func maxDownloads(i int, modifier string, args []string, entries *[]common.DownloadInfo) error {
if !guard(i, len(modifier)) {
return nil
}
n, err := strconv.Atoi(args[i+1])
if err != nil {
return err
}
*entries = (*entries)[0:n]
return nil
}
func guard(i, len int) bool { return i+1 < len-1 }

10
server/playlist/types.go Normal file
View File

@@ -0,0 +1,10 @@
package playlist
import "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
type Metadata struct {
Entries []common.DownloadInfo `json:"entries"`
Count int `json:"playlist_count"`
PlaylistTitle string `json:"title"`
Type string `json:"_type"`
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/configurator"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/dbutil" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/dbutil"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/filebrowser" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/filebrowser"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
@@ -235,6 +236,9 @@ func newServer(c serverConfig) *http.Server {
// Subscriptions // Subscriptions
r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter()) r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter())
// Frontend config store
r.Route("/webconfig", configurator.ApplyRouter())
return &http.Server{Handler: r} return &http.Server{Handler: r}
} }

View File

@@ -48,10 +48,7 @@ func (h *RestHandler) Delete() http.HandlerFunc {
return return
} }
if err := json.NewEncoder(w).Encode("ok"); err != nil { w.WriteHeader(http.StatusOK)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }

View File

@@ -53,6 +53,7 @@ func toDB(dto *domain.Subscription) data.Subscription {
// Delete implements domain.Service. // Delete implements domain.Service.
func (s *Service) Delete(ctx context.Context, id string) error { func (s *Service) Delete(ctx context.Context, id string) error {
s.runner.StopTask(id)
return s.r.Delete(ctx, id) return s.r.Delete(ctx, id)
} }

View File

@@ -7,9 +7,9 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
"time" "time"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
@@ -19,10 +19,12 @@ import (
type TaskRunner interface { type TaskRunner interface {
Submit(subcription *domain.Subscription) error Submit(subcription *domain.Subscription) error
Spawner(ctx context.Context) Spawner(ctx context.Context)
StopTask(id string) error
Recoverer() Recoverer()
} }
type taskPair struct { type monitorTask struct {
Done chan struct{}
Schedule cron.Schedule Schedule cron.Schedule
Subscription *domain.Subscription Subscription *domain.Subscription
} }
@@ -31,21 +33,22 @@ type CronTaskRunner struct {
mq *internal.MessageQueue mq *internal.MessageQueue
db *internal.MemoryDB db *internal.MemoryDB
tasks chan taskPair tasks chan monitorTask
errors chan error errors chan error
running map[string]*monitorTask
} }
func NewCronTaskRunner(mq *internal.MessageQueue, db *internal.MemoryDB) TaskRunner { func NewCronTaskRunner(mq *internal.MessageQueue, db *internal.MemoryDB) TaskRunner {
return &CronTaskRunner{ return &CronTaskRunner{
mq: mq, mq: mq,
db: db, db: db,
tasks: make(chan taskPair), tasks: make(chan monitorTask),
errors: make(chan error), errors: make(chan error),
running: make(map[string]*monitorTask),
} }
} }
const commandTemplate = "-I1 --flat-playlist --print webpage_url $1"
var argsSplitterRe = regexp.MustCompile(`(?mi)[^\s"']+|"([^"]*)"|'([^']*)'`) var argsSplitterRe = regexp.MustCompile(`(?mi)[^\s"']+|"([^"]*)"|'([^']*)'`)
func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error { func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error {
@@ -54,7 +57,8 @@ func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error {
return err return err
} }
job := taskPair{ job := monitorTask{
Done: make(chan struct{}),
Schedule: schedule, Schedule: schedule,
Subscription: subcription, Subscription: subcription,
} }
@@ -64,54 +68,110 @@ func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error {
return nil return nil
} }
// Handles the entire lifecylce of a monitor job.
func (t *CronTaskRunner) Spawner(ctx context.Context) { func (t *CronTaskRunner) Spawner(ctx context.Context) {
for task := range t.tasks { for req := range t.tasks {
t.running[req.Subscription.Id] = &req // keep track of the current job
go func() {
ctx, cancel := context.WithCancel(ctx) // inject into the job's context a cancellation singal
fetcherEvents := t.doFetch(ctx, &req) // retrieve the channel of events of the job
for {
select {
case <-req.Done:
slog.Info("stopping cron job and removing schedule", slog.String("url", req.Subscription.URL))
cancel()
return
case <-fetcherEvents:
slog.Info("finished monitoring channel", slog.String("url", req.Subscription.URL))
}
}
}()
}
}
// Stop a currently scheduled job
func (t *CronTaskRunner) StopTask(id string) error {
task := t.running[id]
if task != nil {
t.running[id].Done <- struct{}{}
delete(t.running, id)
}
return nil
}
// Start a fetcher and notify on a channel when a fetcher has completed
func (t *CronTaskRunner) doFetch(ctx context.Context, req *monitorTask) <-chan struct{} {
completed := make(chan struct{})
// generator func
go func() { go func() {
for { for {
slog.Info("fetching latest video for channel", slog.String("channel", task.Subscription.URL)) sleepFor := t.fetcher(ctx, req)
completed <- struct{}{}
fetcherParams := strings.Split(strings.Replace(commandTemplate, "$1", task.Subscription.URL, 1), " ") time.Sleep(sleepFor)
}
}()
return completed
}
// Perform the retrieval of the latest video of the channel.
// Returns a time.Duration containing the amount of time to the next schedule.
func (t *CronTaskRunner) fetcher(ctx context.Context, req *monitorTask) time.Duration {
slog.Info("fetching latest video for channel", slog.String("channel", req.Subscription.URL))
nextSchedule := time.Until(req.Schedule.Next(time.Now()))
cmd := exec.CommandContext( cmd := exec.CommandContext(
ctx, ctx,
config.Instance().DownloaderPath, config.Instance().DownloaderPath,
fetcherParams..., "-I1",
"--flat-playlist",
"--print", "webpage_url",
req.Subscription.URL,
) )
stdout, err := cmd.Output() stdout, err := cmd.Output()
if err != nil { if err != nil {
t.errors <- err t.errors <- err
return return time.Duration(0)
} }
latestChannelURL := string(bytes.Trim(stdout, "\n")) latestVideoURL := string(bytes.Trim(stdout, "\n"))
// if the download exists there's not point in sending it into the message queue.
exists, err := archive.DownloadExists(ctx, latestVideoURL)
if exists && err == nil {
return nextSchedule
}
p := &internal.Process{ p := &internal.Process{
Url: latestChannelURL, Url: latestVideoURL,
Params: append(argsSplitterRe.FindAllString(task.Subscription.Params, 1), []string{ Params: append(
argsSplitterRe.FindAllString(req.Subscription.Params, 1),
[]string{
"--break-on-existing",
"--download-archive", "--download-archive",
filepath.Join(config.Instance().Dir(), "archive.txt"), filepath.Join(config.Instance().Dir(), "archive.txt"),
}...), }...),
AutoRemove: true, AutoRemove: true,
} }
t.db.Set(p) t.db.Set(p) // give it an id
t.mq.Publish(p) t.mq.Publish(p) // send it to the message queue waiting to be processed
nextSchedule := time.Until(task.Schedule.Next(time.Now()))
slog.Info( slog.Info(
"cron task runner next schedule", "cron task runner next schedule",
slog.String("url", task.Subscription.URL), slog.String("url", req.Subscription.URL),
slog.Any("duration", nextSchedule), slog.Any("duration", nextSchedule),
) )
time.Sleep(nextSchedule) return nextSchedule
}
}()
}
} }
func (t *CronTaskRunner) Recoverer() { func (t *CronTaskRunner) Recoverer() {
panic("Unimplemented") panic("unimplemented")
} }