Compare commits
12 Commits
v3.2.5
...
feat-persi
| Author | SHA1 | Date | |
|---|---|---|---|
| 13c23303a9 | |||
|
|
983915f8aa | ||
| ce2fb13ef2 | |||
| 99069fe5f7 | |||
| 761f26b387 | |||
| eec72bb6e2 | |||
| ceb92d066c | |||
|
|
cf74948840 | ||
|
|
1c62084c7b | ||
| 3c21253562 | |||
| b243c1c958 | |||
|
|
7be5bc7b1f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ frontend/.yarn/install-state.gz
|
|||||||
livestreams.dat
|
livestreams.dat
|
||||||
.vite/deps
|
.vite/deps
|
||||||
archive.txt
|
archive.txt
|
||||||
|
web_config.yml
|
||||||
|
|||||||
@@ -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 :)
|
||||||
|
|
||||||
|
[](https://discord.gg/3Sj9ZZHv)
|
||||||
|
|
||||||
## Some screeshots
|
## Some screeshots
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
12
frontend/pnpm-lock.yaml
generated
12
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>())
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
@@ -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)
|
||||||
})
|
}))
|
||||||
)
|
|
||||||
|
|||||||
33
frontend/src/components/AppTitle.tsx
Normal file
33
frontend/src/components/AppTitle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
pipe(
|
(_) => onClose()
|
||||||
either,
|
|
||||||
matchW(
|
|
||||||
(l) => pushMessage(l, 'error'),
|
|
||||||
(_) => onClose()
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
)()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
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
|
||||||
|
}
|
||||||
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"`
|
||||||
|
}
|
||||||
104
server/configurator/configurator.go
Normal file
104
server/configurator/configurator.go
Normal 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
|
||||||
|
}
|
||||||
103
server/configurator/handlers.go
Normal file
103
server/configurator/handlers.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,29 +31,14 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// struct representing the current status of the memoryDB
|
// struct representing the current status of the memoryDB
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
86
server/playlist/modifiers.go
Normal file
86
server/playlist/modifiers.go
Normal 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
10
server/playlist/types.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
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 {
|
for {
|
||||||
slog.Info("fetching latest video for channel", slog.String("channel", task.Subscription.URL))
|
select {
|
||||||
|
case <-req.Done:
|
||||||
fetcherParams := strings.Split(strings.Replace(commandTemplate, "$1", task.Subscription.URL, 1), " ")
|
slog.Info("stopping cron job and removing schedule", slog.String("url", req.Subscription.URL))
|
||||||
|
cancel()
|
||||||
cmd := exec.CommandContext(
|
|
||||||
ctx,
|
|
||||||
config.Instance().DownloaderPath,
|
|
||||||
fetcherParams...,
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
t.errors <- err
|
|
||||||
return
|
return
|
||||||
|
case <-fetcherEvents:
|
||||||
|
slog.Info("finished monitoring channel", slog.String("url", req.Subscription.URL))
|
||||||
}
|
}
|
||||||
|
|
||||||
latestChannelURL := string(bytes.Trim(stdout, "\n"))
|
|
||||||
|
|
||||||
p := &internal.Process{
|
|
||||||
Url: latestChannelURL,
|
|
||||||
Params: append(argsSplitterRe.FindAllString(task.Subscription.Params, 1), []string{
|
|
||||||
"--download-archive",
|
|
||||||
filepath.Join(config.Instance().Dir(), "archive.txt"),
|
|
||||||
}...),
|
|
||||||
AutoRemove: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.db.Set(p)
|
|
||||||
t.mq.Publish(p)
|
|
||||||
|
|
||||||
nextSchedule := time.Until(task.Schedule.Next(time.Now()))
|
|
||||||
|
|
||||||
slog.Info(
|
|
||||||
"cron task runner next schedule",
|
|
||||||
slog.String("url", task.Subscription.URL),
|
|
||||||
slog.Any("duration", nextSchedule),
|
|
||||||
)
|
|
||||||
|
|
||||||
time.Sleep(nextSchedule)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTaskRunner) Recoverer() {
|
// Stop a currently scheduled job
|
||||||
panic("Unimplemented")
|
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() {
|
||||||
|
for {
|
||||||
|
sleepFor := t.fetcher(ctx, req)
|
||||||
|
completed <- struct{}{}
|
||||||
|
|
||||||
|
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(
|
||||||
|
ctx,
|
||||||
|
config.Instance().DownloaderPath,
|
||||||
|
"-I1",
|
||||||
|
"--flat-playlist",
|
||||||
|
"--print", "webpage_url",
|
||||||
|
req.Subscription.URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.errors <- err
|
||||||
|
return time.Duration(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Url: latestVideoURL,
|
||||||
|
Params: append(
|
||||||
|
argsSplitterRe.FindAllString(req.Subscription.Params, 1),
|
||||||
|
[]string{
|
||||||
|
"--break-on-existing",
|
||||||
|
"--download-archive",
|
||||||
|
filepath.Join(config.Instance().Dir(), "archive.txt"),
|
||||||
|
}...),
|
||||||
|
AutoRemove: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.db.Set(p) // give it an id
|
||||||
|
t.mq.Publish(p) // send it to the message queue waiting to be processed
|
||||||
|
|
||||||
|
slog.Info(
|
||||||
|
"cron task runner next schedule",
|
||||||
|
slog.String("url", req.Subscription.URL),
|
||||||
|
slog.Any("duration", nextSchedule),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nextSchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTaskRunner) Recoverer() {
|
||||||
|
panic("unimplemented")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user