Compare commits
40 Commits
252-postpr
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c06485880 | ||
|
|
ccb6bbe3e6 | ||
| 9ca7bb9377 | |||
| bce696fc67 | |||
| 22caf8899b | |||
| 2a11f64935 | |||
|
|
f4a0f688af | ||
|
|
14a03d6a77 | ||
|
|
8a73079fad | ||
| f578f44cfd | |||
| cbe16c5c6c | |||
| 3cebaf7f61 | |||
|
|
2d2cb1dc3a | ||
|
|
43bcc40907 | ||
|
|
2af27e51be | ||
|
|
8c18242aaf | ||
|
|
66bebb2529 | ||
|
|
e223e030ac | ||
| e4362468f7 | |||
| 6880f60d14 | |||
|
|
5d4aa7e2a3 | ||
|
|
2845196bc7 | ||
|
|
983915f8aa | ||
| ce2fb13ef2 | |||
| 99069fe5f7 | |||
| 761f26b387 | |||
| eec72bb6e2 | |||
| ceb92d066c | |||
|
|
cf74948840 | ||
|
|
1c62084c7b | ||
| 3c21253562 | |||
| b243c1c958 | |||
|
|
7be5bc7b1f | ||
|
|
65960fb560 | ||
|
|
1141903512 | ||
| ff93bd552f | |||
|
|
016d8557e6 | ||
| 5e9f92a06f | |||
| cc6a562e9e | |||
|
|
67b01f9e0b |
1
.deploystack/docker-run.txt
Normal file
1
.deploystack/docker-run.txt
Normal file
@@ -0,0 +1 @@
|
||||
docker run -d -p 3033:3033 -v /downloads:/downloads marcobaobao/yt-dlp-webui
|
||||
27
.devcontainer/devcontainer.json
Normal file
27
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,27 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/go
|
||||
{
|
||||
"name": "Go",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-extra/features/pnpm:2": {},
|
||||
"ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/yt-dlp:2": {}
|
||||
}
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "go version"
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
result/
|
||||
result
|
||||
dist
|
||||
.pnpm-store/
|
||||
.pnpm-debug.log
|
||||
node_modules
|
||||
.env
|
||||
@@ -20,9 +21,11 @@ cookies.txt
|
||||
__debug*
|
||||
ui/
|
||||
.idea
|
||||
.idea/
|
||||
frontend/.pnp.cjs
|
||||
frontend/.pnp.loader.mjs
|
||||
frontend/.yarn/install-state.gz
|
||||
.db.lock
|
||||
livestreams.dat
|
||||
.git
|
||||
.vite/deps
|
||||
archive.txt
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
result/
|
||||
result
|
||||
dist
|
||||
.pnpm-store/
|
||||
.pnpm-debug.log
|
||||
node_modules
|
||||
.env
|
||||
@@ -26,3 +27,6 @@ frontend/.pnp.loader.mjs
|
||||
frontend/.yarn/install-state.gz
|
||||
.db.lock
|
||||
livestreams.dat
|
||||
.vite/deps
|
||||
archive.txt
|
||||
twitch-monitor.dat
|
||||
|
||||
@@ -24,11 +24,12 @@ COPY --from=ui /usr/src/yt-dlp-webui/frontend /usr/src/yt-dlp-webui/frontend
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# dependencies ----------------------------------------------------------------
|
||||
FROM alpine:edge
|
||||
# Runtime ---------------------------------------------------------------------
|
||||
FROM python:3.13.2-alpine3.21
|
||||
|
||||
RUN apk update && \
|
||||
apk add ffmpeg yt-dlp ca-certificates curl wget psmisc
|
||||
apk add ffmpeg ca-certificates curl wget gnutls --no-cache && \
|
||||
pip install "yt-dlp[default,curl-cffi,mutagen,pycryptodomex,phantomjs,secretstorage]"
|
||||
|
||||
VOLUME /downloads /config
|
||||
|
||||
@@ -39,4 +40,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
||||
ENV JWT_SECRET=secret
|
||||
|
||||
EXPOSE 3033
|
||||
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]
|
||||
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]
|
||||
|
||||
15
README.md
15
README.md
@@ -25,6 +25,11 @@ docker pull ghcr.io/marcopiovanello/yt-dlp-web-ui:latest
|
||||
|
||||
*Keeps the project alive!* 😃
|
||||
|
||||
## Community stuff
|
||||
Feel free to join :)
|
||||
|
||||
[Discord](https://discord.gg/GZAX5FfGzE)
|
||||
|
||||
## Some screeshots
|
||||

|
||||

|
||||
@@ -110,6 +115,16 @@ services:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### ⚡ One-Click Deploy
|
||||
|
||||
| Cloud Provider | Deploy Button |
|
||||
|----------------|---------------|
|
||||
| AWS | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=aws&language=cfn"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/aws.svg" height="38"></a> |
|
||||
| DigitalOcean | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=do&language=dop"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/do.svg" height="38"></a> |
|
||||
| Render | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=rnd&language=rnd"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/rnd.svg" height="38"></a> |
|
||||
|
||||
<sub>Generated by <a href="https://deploystack.io/c/marcopiovanello-yt-dlp-web-ui" target="_blank">DeployStack.io</a></sub>
|
||||
|
||||
## [Prebuilt binaries](https://github.com/marcopiovanello/yt-dlp-web-ui/releases) installation
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "yt-dlp-webui",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.6",
|
||||
"description": "Frontend compontent of yt-dlp-webui",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build"
|
||||
},
|
||||
"type": "module",
|
||||
"author": "marcopiovanello",
|
||||
"license": "GPL-3.0-only",
|
||||
"private": true,
|
||||
@@ -17,11 +18,11 @@
|
||||
"@mui/icons-material": "^6.2.0",
|
||||
"@mui/material": "^6.2.0",
|
||||
"fp-ts": "^2.16.5",
|
||||
"jotai": "^2.10.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"react-virtuoso": "^4.7.11",
|
||||
"jotai": "^2.10.3",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import LiveTvIcon from '@mui/icons-material/LiveTv'
|
||||
import Menu from '@mui/icons-material/Menu'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||
import UpdateIcon from '@mui/icons-material/Update'
|
||||
import { Box, createTheme } from '@mui/material'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Divider from '@mui/material/Divider'
|
||||
@@ -27,6 +28,7 @@ import Footer from './components/Footer'
|
||||
import Logout from './components/Logout'
|
||||
import SocketSubscriber from './components/SocketSubscriber'
|
||||
import ThemeToggler from './components/ThemeToggler'
|
||||
import TwitchIcon from './components/TwitchIcon'
|
||||
import { useI18n } from './hooks/useI18n'
|
||||
import Toaster from './providers/ToasterProvider'
|
||||
import { getAccentValue } from './utils'
|
||||
@@ -140,6 +142,32 @@ export default function Layout() {
|
||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/subscriptions'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton>
|
||||
<ListItemIcon>
|
||||
<UpdateIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={i18n.t('subscriptionsButtonLabel')} />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/twitch'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton>
|
||||
<ListItemIcon>
|
||||
<TwitchIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={"Twitch"} />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/monitor'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
|
||||
@@ -21,6 +21,7 @@ keys:
|
||||
overridesAnchor: Anul·lacions
|
||||
pathOverrideOption: Sobreescriure en la ruta de sortida
|
||||
filenameOverrideOption: Sobreescriure el nom del fitxer
|
||||
autoFileExtensionOption: Afegeix l'extensió de fitxer automàticament
|
||||
customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat)
|
||||
customPath: Ruta personalitzada
|
||||
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
|
||||
@@ -68,3 +69,12 @@ keys:
|
||||
noFilesFound: 'No Files Found'
|
||||
tableView: 'Table View'
|
||||
deleteSelected: 'Delete selected'
|
||||
subscriptionsButtonLabel: 'Subscriptions'
|
||||
subscriptionsEmptyLabel: 'No subscriptions'
|
||||
subscriptionsURLInput: 'Channel URL'
|
||||
subscriptionsInfo: |
|
||||
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||
cronExpressionLabel: 'Cron expression'
|
||||
editButtonLabel: 'Edit'
|
||||
newSubscriptionButton: New subscription
|
||||
@@ -22,6 +22,7 @@ keys:
|
||||
overridesAnchor: Überschreibungen
|
||||
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
|
||||
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
|
||||
autoFileExtensionOption: Dateierweiterung automatisch hinzufügen
|
||||
customFilename: Benutzerdefinierter Dateiname (Leer lassen um Standardwert zu nutzen)
|
||||
customPath: Benutzerdefinierter Pfad
|
||||
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (Auf viel Macht folgt große Verantwortung)
|
||||
@@ -70,3 +71,12 @@ keys:
|
||||
noFilesFound: 'Keine Dateien gefunden'
|
||||
tableView: 'Tabellenansicht'
|
||||
deleteSelected: 'Ausgewählte löschen'
|
||||
subscriptionsButtonLabel: 'Subscriptions'
|
||||
subscriptionsEmptyLabel: 'No subscriptions'
|
||||
subscriptionsURLInput: 'Channel URL'
|
||||
subscriptionsInfo: |
|
||||
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||
cronExpressionLabel: 'Cron expression'
|
||||
editButtonLabel: 'Edit'
|
||||
newSubscriptionButton: New subscription
|
||||
@@ -22,15 +22,16 @@ keys:
|
||||
overridesAnchor: Overrides
|
||||
pathOverrideOption: Enable output path overriding
|
||||
filenameOverrideOption: Enable output file name overriding
|
||||
autoFileExtensionOption: Automatically add file extension
|
||||
customFilename: Custom filename (leave blank to use default)
|
||||
customPath: Custom path
|
||||
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
|
||||
customArgsInput: Custom yt-dlp arguments
|
||||
rpcConnErr: Error while conencting to RPC server
|
||||
rpcConnErr: Error while connecting to RPC server
|
||||
splashText: No active downloads
|
||||
archiveTitle: Archive
|
||||
clipboardAction: Copied URL to clipboard
|
||||
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
|
||||
playlistCheckbox: Download playlist
|
||||
restartAppMessage: Needs a page reload to take effect
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
||||
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
|
||||
@@ -69,4 +70,17 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
clearCompletedButton: 'Clear completed'
|
||||
twitchIntegrationInfo: |
|
||||
To enable monitoring Twitch streams follow this wiki page.
|
||||
https://github.com/marcopiovanello/yt-dlp-web-ui/wiki/Twitch-integration
|
||||
@@ -21,6 +21,7 @@ keys:
|
||||
overridesAnchor: Anulaciones
|
||||
pathOverrideOption: Sobreescribir en la ruta de salida
|
||||
filenameOverrideOption: Sobreescribir el nombre del fichero
|
||||
autoFileExtensionOption: Agregar extensión de archivo automáticamente
|
||||
customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
|
||||
customPath: Ruta personalizada
|
||||
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
||||
@@ -67,4 +68,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -22,6 +22,7 @@ keys:
|
||||
overridesAnchor: Remplacer
|
||||
pathOverrideOption: Activer le remplacement du chemin de sortie
|
||||
filenameOverrideOption: Activer le remplacement du nom du fichier de sortie
|
||||
autoFileExtensionOption: Ajouter automatiquement l'extension de fichier
|
||||
customFilename: Nom de fichier personnalisé (laisser vide pour utiliser le nom par défaut)
|
||||
customPath: Chemin personnalisé
|
||||
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
|
||||
@@ -71,4 +72,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -22,6 +22,7 @@ keys:
|
||||
overridesAnchor: Felülbírálások
|
||||
pathOverrideOption: Letöltési útvonal felülbírálása
|
||||
filenameOverrideOption: Letöltési fájlnév felülbírálása
|
||||
autoFileExtensionOption: Automatikus fájlkiterjesztés
|
||||
customFilename: Egyedi fájlnév (hagyd üresen, hogy a fájlnév automatikusan generálódjon)
|
||||
customPath: Egyedi útvonal
|
||||
customArgs: Egyedi yt-dlp argumentumok (Nagy hatalommal nagy felelősség jár.)
|
||||
@@ -69,4 +70,13 @@ keys:
|
||||
deleteCookies: Sütik törlése
|
||||
noFilesFound: 'Nem található fájlok'
|
||||
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:
|
||||
urlInput: URL Video (uno per linea)
|
||||
statusTitle: Stato
|
||||
startButton: Inizia
|
||||
statusReady: Pronto
|
||||
selectFormatButton: Seziona formato
|
||||
startButton: Inizia
|
||||
abortAllButton: Termina tutto
|
||||
updateBinButton: Aggiorna yt-dlp
|
||||
darkThemeButton: Tema scuro
|
||||
@@ -21,51 +22,61 @@ keys:
|
||||
overridesAnchor: Sovrascritture
|
||||
pathOverrideOption: Abilita sovrascrittura percorso di output
|
||||
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
||||
customFilename: Custom filename (leave blank to use default)
|
||||
customPath: Custom path
|
||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||
customArgsInput: Custom yt-dlp arguments
|
||||
rpcConnErr: Error nella connessione al server RPC
|
||||
autoFileExtensionOption: Aggiungi estensione automaticamente
|
||||
customFilename: Nome file personalizzato (lascia vuoto per utilizzare quello predefinito)
|
||||
customPath: Percorso personalizzato
|
||||
customArgs: Abilita argomenti yt-dlp personalizzati (grande potere = grandi responsabilità)
|
||||
customArgsInput: Argomenti yt-dlp personalizzati
|
||||
rpcConnErr: Errore nella connessione al server RPC
|
||||
splashText: Nessun download attivo
|
||||
archiveTitle: Archivio
|
||||
clipboardAction: URL copiato negli appunti
|
||||
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
|
||||
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
||||
restartAppMessage: La finestra deve essere ricaricata affinché abbia effetto
|
||||
servedFromReverseProxyCheckbox: È dietro un reverse proxy
|
||||
urlBase: base URL, per supporto a reverse proxy (subdir), default vuoto
|
||||
newDownloadButton: Nuovo download
|
||||
homeButtonLabel: Home
|
||||
archiveButtonLabel: Archive
|
||||
settingsButtonLabel: Settings
|
||||
rpcAuthenticationLabel: RPC authentication
|
||||
themeTogglerLabel: Theme toggler
|
||||
loadingLabel: Loading...
|
||||
archiveButtonLabel: Archivio
|
||||
settingsButtonLabel: Impostazioni
|
||||
rpcAuthenticationLabel: Autenticazione RPC
|
||||
themeTogglerLabel: Selettore Tema
|
||||
loadingLabel: Caricamento...
|
||||
appTitle: Titolo applicazione
|
||||
savedTemplates: Template salvati
|
||||
templatesEditor: Editor template
|
||||
templatesEditorNameLabel: Nome template
|
||||
templatesEditorContentLabel: Contentunto template
|
||||
savedTemplates: Modelli salvati
|
||||
templatesEditor: Editor modelli
|
||||
templatesEditorNameLabel: Nome modello
|
||||
templatesEditorContentLabel: Contenuto del modello
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
bulkDownload: 'Download files in a zip archive'
|
||||
templatesReloadInfo: To register a new template it might need a page reload.
|
||||
livestreamURLInput: Livestream URL
|
||||
livestreamStatusWaiting: Waiting/Wait start
|
||||
livestreamStatusDownloading: Downloading
|
||||
livestreamStatusCompleted: Completed
|
||||
livestreamStatusErrored: Errored
|
||||
livestreamStatusUnknown: Unknown
|
||||
livestreamNoMonitoring: No livestreams monitored
|
||||
livestreamDownloadInfo: |
|
||||
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
|
||||
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
|
||||
Once started the livestream will be migrated to the downloads page.
|
||||
livestreamExperimentalWarning: This feature is still experimental. Something might break!
|
||||
accentSelect: 'Accent'
|
||||
urlBase: base URL, per supporto a reverse proxy (subdir), default vuoto
|
||||
bulkDownload: 'Scaricare i file in un archivio zip'
|
||||
rpcPollingTimeTitle: Intervallo di polling RPC
|
||||
rpcPollingTimeDescription: Un intervallo più corto implica un maggior utilizzo di CPU (lato client e server)
|
||||
generalDownloadSettings: 'General Download Settings'
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
tableView: 'Table View'
|
||||
deleteSelected: 'Delete selected'
|
||||
templatesReloadInfo: Per registrare un nuovo modello potrebbe essere necessario ricaricare la pagina.
|
||||
livestreamURLInput: Livestream URL
|
||||
livestreamStatusWaiting: Attesa inizio
|
||||
livestreamStatusDownloading: Downloading
|
||||
livestreamStatusCompleted: Completato
|
||||
livestreamStatusErrored: Errore
|
||||
livestreamStatusUnknown: Sconosciuto
|
||||
livestreamNoMonitoring: Nessun livestream monitorato
|
||||
livestreamDownloadInfo: |
|
||||
Questo monitorerà il livestream ancora da avviare. Ogni processo verrà eseguito con --wait-for-video 10.
|
||||
Se viene fornito un livestream già avviato, questo verrà comunque scaricato, ma il suo progresso non verrà monitorato.
|
||||
Una volta avviato, il livestream verrà migrato nella pagina dei download.
|
||||
livestreamExperimentalWarning: Questa funzione è ancora sperimentale. Qualcosa potrebbe rompersi!
|
||||
accentSelect: 'Accent'
|
||||
generalDownloadSettings: 'Impostazioni generali di download'
|
||||
deleteCookies: Elimina Cookies
|
||||
noFilesFound: 'Nessun file trovato'
|
||||
tableView: 'Vista Tabella'
|
||||
deleteSelected: 'Elimina selezionati'
|
||||
subscriptionsButtonLabel: 'Abbonamenti'
|
||||
subscriptionsEmptyLabel: 'Nessuna iscrizione'
|
||||
subscriptionsURLInput: 'URL Canale'
|
||||
subscriptionsInfo: |
|
||||
Iscrive a un canale definito. Verrà scaricato solo l'ultimo video.
|
||||
Il lavoro di monitoraggio sarà programmato/attivato da un'espressione cron definita (se lasciata vuota, l'impostazione predefinita è ogni 5 minuti).
|
||||
cronExpressionLabel: 'Espressione Cron'
|
||||
editButtonLabel: 'Modifica'
|
||||
newSubscriptionButton: Nuova iscrizione
|
||||
@@ -22,6 +22,7 @@ keys:
|
||||
overridesAnchor: 上書き
|
||||
pathOverrideOption: 保存するディレクトリ
|
||||
filenameOverrideOption: ファイル名の上書き
|
||||
autoFileExtensionOption: 自動ファイル拡張子
|
||||
customFilename: (空白の場合は元のファイル名)
|
||||
customPath: 保存先
|
||||
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
||||
@@ -68,4 +69,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -21,6 +21,7 @@ keys:
|
||||
overridesAnchor: Overrides
|
||||
pathOverrideOption: Enable output path overriding
|
||||
filenameOverrideOption: Enable output file name overriding
|
||||
autoFileExtensionOption: 자동으로 파일 확장자 추가
|
||||
customFilename: Custom filename (leave blank to use default)
|
||||
customPath: Custom path
|
||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||
@@ -67,4 +68,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -21,6 +21,7 @@ keys:
|
||||
overridesAnchor: Przedefiniuj
|
||||
pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej
|
||||
filenameOverrideOption: Aktywuj zastępowanie nazwy pliku źródłowego
|
||||
autoFileExtensionOption: Automatyczne rozszerzenie pliku
|
||||
customFilename: Wprowadź nazwę pliku (pozostaw puste, aby użyć nazwy domyślnej)
|
||||
customPath: Ustaw ścieżkę
|
||||
customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność)
|
||||
@@ -67,4 +68,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -22,6 +22,7 @@ keys:
|
||||
overridesAnchor: Substituições
|
||||
pathOverrideOption: Habilitar substituição do caminho de saída
|
||||
filenameOverrideOption: Habilitar substituição do nome do arquivo de saída
|
||||
autoFileExtensionOption: Adicionar extensão de arquivo automaticamente
|
||||
customFilename: Nome de arquivo personalizado (deixe em branco para usar o padrão)
|
||||
customPath: Caminho personalizado
|
||||
customArgs: Habilitar argumentos personalizados do yt-dlp (grandes poderes = grandes responsabilidades)
|
||||
@@ -69,4 +70,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -21,6 +21,7 @@ keys:
|
||||
overridesAnchor: Переопределить
|
||||
pathOverrideOption: Активировать переопределение выходного пути
|
||||
filenameOverrideOption: Активировать переопределение имени выходного файла
|
||||
autoFileExtensionOption: Автоматическое расширение файла
|
||||
customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию)
|
||||
customPath: Задать путь
|
||||
customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность)
|
||||
@@ -67,4 +68,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -22,6 +22,7 @@ keys:
|
||||
overridesAnchor: Överskrivningar
|
||||
pathOverrideOption: Tillåt överskrivning av filsökvägen
|
||||
filenameOverrideOption: Tillåt överskrivning av filnamn
|
||||
autoFileExtensionOption: Lägg till filändelse automatiskt
|
||||
customFilename: Eget filnamn (lämna blankt för standardnamn)
|
||||
customPath: Egen filsökväg
|
||||
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
|
||||
@@ -69,4 +70,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -21,6 +21,7 @@ keys:
|
||||
overridesAnchor: Перевизначити
|
||||
pathOverrideOption: Активувати перевизначення вихідного шляху
|
||||
filenameOverrideOption: Активувати перевизначення імені вихідного файлу
|
||||
autoFileExtensionOption: Автоматичне додавання розширення файлу
|
||||
customFilename: Введіть ім'я файлу (залишіть порожнім, щоб використовувати значення за замовчуванням)
|
||||
customPath: Задати шлях
|
||||
customArgs: Включити аргументи, що настроюються yt-dlp (велика сила = велика відповідальність)
|
||||
@@ -67,4 +68,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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
|
||||
@@ -22,6 +22,7 @@ keys:
|
||||
overridesAnchor: 覆盖
|
||||
pathOverrideOption: 启用输出路径覆盖
|
||||
filenameOverrideOption: 启用输出文件名覆盖
|
||||
autoFileExtensionOption: 自动文件扩展名
|
||||
customFilename: 自定义文件名(留空使用默认值)
|
||||
customPath: 自定义路径
|
||||
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
|
||||
@@ -69,4 +70,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
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,10 +1,10 @@
|
||||
import { getOrElse } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { CustomTemplate } from '../types'
|
||||
import { serverSideCookiesState, serverURL } from './settings'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
|
||||
export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
|
||||
await get(serverSideCookiesState)
|
||||
@@ -22,12 +22,6 @@ export const filenameTemplateState = atomWithStorage(
|
||||
localStorage.getItem('lastFilenameTemplate') ?? ''
|
||||
)
|
||||
|
||||
export const downloadTemplateState = atom<Promise<string>>(async (get) =>
|
||||
`${get(customArgsState)} ${await get(cookiesTemplateState)}`
|
||||
.replace(/ +/g, ' ')
|
||||
.trim()
|
||||
)
|
||||
|
||||
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
|
||||
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
||||
const either = await task()
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface SettingsState {
|
||||
cliArgs: string
|
||||
formatSelection: boolean
|
||||
fileRenaming: boolean
|
||||
autoFileExtension: boolean
|
||||
pathOverriding: boolean
|
||||
enableCustomArgs: boolean
|
||||
listView: boolean
|
||||
@@ -82,6 +83,11 @@ export const fileRenamingState = atomWithStorage(
|
||||
localStorage.getItem('file-renaming') === 'true'
|
||||
)
|
||||
|
||||
export const autoFileExtensionState = atomWithStorage(
|
||||
'auto-file-extension',
|
||||
localStorage.getItem('auto-file-extension') === 'true'
|
||||
)
|
||||
|
||||
export const pathOverridingState = atomWithStorage(
|
||||
'path-overriding',
|
||||
localStorage.getItem('path-overriding') === 'true'
|
||||
@@ -115,11 +121,18 @@ export const appTitleState = atomWithStorage(
|
||||
export const serverAddressAndPortState = atom((get) => {
|
||||
if (get(servedFromReverseProxySubDirState)) {
|
||||
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
|
||||
.replaceAll('"', '') // XXX: atomWithStorage uses JSON.stringify to serialize
|
||||
.replaceAll('//', '/') // which puts extra double quotes.
|
||||
}
|
||||
if (get(servedFromReverseProxyState)) {
|
||||
return `${get(serverAddressState)}`
|
||||
.replaceAll('"', '')
|
||||
}
|
||||
return `${get(serverAddressState)}:${get(serverPortState)}`
|
||||
|
||||
const sap = `${get(serverAddressState)}:${get(serverPortState)}`
|
||||
.replaceAll('"', '')
|
||||
|
||||
return sap.endsWith('/') ? sap.slice(0, -1) : sap
|
||||
})
|
||||
|
||||
export const serverURL = atom((get) =>
|
||||
@@ -128,15 +141,17 @@ export const serverURL = atom((get) =>
|
||||
|
||||
export const rpcWebSocketEndpoint = atom((get) => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
|
||||
}
|
||||
)
|
||||
const sap = get(serverAddressAndPortState)
|
||||
|
||||
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/ws`
|
||||
})
|
||||
|
||||
export const rpcHTTPEndpoint = atom((get) => {
|
||||
const proto = window.location.protocol
|
||||
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
|
||||
}
|
||||
)
|
||||
const sap = get(serverAddressAndPortState)
|
||||
|
||||
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/http`
|
||||
})
|
||||
|
||||
export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe(
|
||||
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
|
||||
@@ -168,10 +183,10 @@ export const settingsState = atom<SettingsState>((get) => ({
|
||||
cliArgs: get(latestCliArgumentsState),
|
||||
formatSelection: get(formatSelectionState),
|
||||
fileRenaming: get(fileRenamingState),
|
||||
autoFileExtension: get(autoFileExtensionState),
|
||||
pathOverriding: get(pathOverridingState),
|
||||
enableCustomArgs: get(enableCustomArgsState),
|
||||
listView: get(listViewState),
|
||||
servedFromReverseProxy: get(servedFromReverseProxyState),
|
||||
appTitle: get(appTitleState)
|
||||
})
|
||||
)
|
||||
}))
|
||||
|
||||
35
frontend/src/components/CustomArgsTextField.tsx
Normal file
35
frontend/src/components/CustomArgsTextField.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TextField } from '@mui/material'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { customArgsState } from '../atoms/downloadTemplate'
|
||||
import { settingsState } from '../atoms/settings'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const CustomArgsTextField: React.FC = () => {
|
||||
const { i18n } = useI18n()
|
||||
|
||||
const settings = useAtomValue(settingsState)
|
||||
|
||||
const [customArgs, setCustomArgs] = useAtom(customArgsState)
|
||||
|
||||
useEffect(() => {
|
||||
setCustomArgs('')
|
||||
}, [])
|
||||
|
||||
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomArgs(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={i18n.t('customArgsInput')}
|
||||
variant="outlined"
|
||||
onChange={handleCustomArgsChange}
|
||||
value={customArgs}
|
||||
disabled={settings.formatSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomArgsTextField
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
TextField
|
||||
} from '@mui/material'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
@@ -25,14 +28,13 @@ import {
|
||||
FC,
|
||||
Suspense,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition
|
||||
} from 'react'
|
||||
import {
|
||||
cookiesTemplateState,
|
||||
customArgsState,
|
||||
downloadTemplateState,
|
||||
filenameTemplateState,
|
||||
savedTemplatesState
|
||||
} from '../atoms/downloadTemplate'
|
||||
@@ -44,6 +46,7 @@ import { useI18n } from '../hooks/useI18n'
|
||||
import { useRPC } from '../hooks/useRPC'
|
||||
import type { DLMetadata } from '../types'
|
||||
import { toFormatArgs } from '../utils'
|
||||
import CustomArgsTextField from './CustomArgsTextField'
|
||||
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
||||
import LoadingBackdrop from './LoadingBackdrop'
|
||||
|
||||
@@ -66,8 +69,9 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
const settings = useAtomValue(settingsState)
|
||||
const isConnected = useAtomValue(connectedState)
|
||||
const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
|
||||
const downloadTemplate = useAtomValue(downloadTemplateState)
|
||||
const savedTemplates = useAtomValue(savedTemplatesState)
|
||||
const customArgs = useAtomValue(customArgsState)
|
||||
const cookies = useAtomValue(cookiesTemplateState)
|
||||
|
||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||
@@ -75,14 +79,14 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
|
||||
|
||||
const [customArgs, setCustomArgs] = useAtom(customArgsState)
|
||||
|
||||
const [downloadPath, setDownloadPath] = useState('')
|
||||
|
||||
const [filenameTemplate, setFilenameTemplate] = useAtom(
|
||||
filenameTemplateState
|
||||
)
|
||||
|
||||
const [fileExtension, setFileExtension] = useState('.%(ext)s')
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
|
||||
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||
@@ -96,10 +100,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
useEffect(() => {
|
||||
setCustomArgs('')
|
||||
}, [open])
|
||||
|
||||
/**
|
||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||
*/
|
||||
@@ -110,12 +110,16 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
||||
|
||||
const downloadTemplate = `${customArgs} ${cookies}`
|
||||
.replace(/ +/g, ' ')
|
||||
.trim()
|
||||
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
client.download({
|
||||
url: immediate || line,
|
||||
args: `${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||
pathOverride: downloadPath ?? '',
|
||||
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
||||
renameTo: settings.fileRenaming ? filenameTemplate + (settings.autoFileExtension ? fileExtension : '') : '',
|
||||
playlist: isPlaylist,
|
||||
})
|
||||
|
||||
@@ -169,8 +173,8 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
setFilenameTemplate(e.target.value)
|
||||
}
|
||||
|
||||
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomArgs(e.target.value)
|
||||
const handleFileExtensionChange = (e: SelectChangeEvent<string>) => {
|
||||
setFileExtension(e.target.value)
|
||||
}
|
||||
|
||||
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -268,25 +272,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||
{
|
||||
settings.enableCustomArgs &&
|
||||
{settings.enableCustomArgs &&
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={i18n.t('customArgsInput')}
|
||||
variant="outlined"
|
||||
onChange={handleCustomArgsChange}
|
||||
value={customArgs}
|
||||
disabled={
|
||||
!isConnected ||
|
||||
(settings.formatSelection && downloadFormats != null)
|
||||
}
|
||||
/>
|
||||
<CustomArgsTextField />
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.fileRenaming &&
|
||||
<Grid item xs={settings.pathOverriding ? 8 : 12}>
|
||||
<Grid item xs={
|
||||
!settings.autoFileExtension && !settings.pathOverriding
|
||||
? 12
|
||||
: !settings.autoFileExtension && settings.pathOverriding
|
||||
? 8
|
||||
: settings.autoFileExtension && !settings.pathOverriding
|
||||
? 10
|
||||
: 6
|
||||
}>
|
||||
<TextField
|
||||
sx={{ mt: 1 }}
|
||||
ref={customFilenameInputRef}
|
||||
@@ -302,6 +303,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.autoFileExtension &&
|
||||
<Grid item xs={2}>
|
||||
<Select
|
||||
sx={{ mt: 1 }}
|
||||
fullWidth
|
||||
label={i18n.t('autoFileExtension')}
|
||||
value={fileExtension}
|
||||
onChange={handleFileExtensionChange}
|
||||
variant="outlined">
|
||||
<MenuItem value=".%(ext)s">Auto</MenuItem>
|
||||
<MenuItem value=".mp4">mp4</MenuItem>
|
||||
<MenuItem value=".mkv">mkv</MenuItem>
|
||||
</Select>
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.pathOverriding &&
|
||||
<Grid item xs={4}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
|
||||
import ClearAllIcon from '@mui/icons-material/ClearAll'
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||
import FolderZipIcon from '@mui/icons-material/FolderZip'
|
||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||
@@ -42,6 +43,11 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
||||
tooltipTitle={i18n.t('bulkDownload')}
|
||||
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
|
||||
/>
|
||||
<SpeedDialAction
|
||||
icon={<ClearAllIcon />}
|
||||
tooltipTitle={i18n.t('clearCompletedButton')}
|
||||
onClick={() => client.clearCompleted()}
|
||||
/>
|
||||
<SpeedDialAction
|
||||
icon={<DeleteForeverIcon />}
|
||||
tooltipTitle={i18n.t('abortAllButton')}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
@@ -20,13 +18,13 @@ import {
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { CustomTemplate } from '../types'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import TemplateTextField from './TemplateTextField'
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
|
||||
22
frontend/src/components/TwitchIcon.tsx
Normal file
22
frontend/src/components/TwitchIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { settingsState } from '../atoms/settings'
|
||||
|
||||
const TwitchIcon: React.FC = () => {
|
||||
const { theme } = useAtomValue(settingsState)
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={24}
|
||||
height={24}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ fill: theme === 'dark' ? '#fff' : '#757575' }}
|
||||
>
|
||||
<title>Twitch</title>
|
||||
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default TwitchIcon
|
||||
@@ -7,6 +7,7 @@ const VersionIndicator: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<Chip label={`UI v3.2.5`} variant="outlined" size="small" />
|
||||
<Chip label={`RPC v${version.rpcVersion}`} variant="outlined" size="small" />
|
||||
<Chip label={`yt-dlp v${version.ytdlpVersion}`} variant="outlined" size="small" />
|
||||
</div>
|
||||
|
||||
38
frontend/src/components/subscriptions/NoSubscriptions.tsx
Normal file
38
frontend/src/components/subscriptions/NoSubscriptions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import UpdateIcon from '@mui/icons-material/Update'
|
||||
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
|
||||
const FlexContainer = styled(Container)({
|
||||
display: 'flex',
|
||||
minWidth: '100%',
|
||||
minHeight: '80vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
})
|
||||
|
||||
const Title = styled(Typography)({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: '0.5rem'
|
||||
})
|
||||
|
||||
|
||||
export default function NoSubscriptions() {
|
||||
const { i18n } = useI18n()
|
||||
|
||||
return (
|
||||
<FlexContainer>
|
||||
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||
<UpdateIcon />
|
||||
</SvgIcon>
|
||||
</Title>
|
||||
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||
{i18n.t('subscriptionsEmptyLabel')}
|
||||
</Title>
|
||||
</FlexContainer>
|
||||
)
|
||||
}
|
||||
164
frontend/src/components/subscriptions/SubscriptionsDialog.tsx
Normal file
164
frontend/src/components/subscriptions/SubscriptionsDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Dialog,
|
||||
Grid,
|
||||
IconButton,
|
||||
Paper,
|
||||
Slide,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { forwardRef, startTransition, useState } from 'react'
|
||||
import { customArgsState } from '../../atoms/downloadTemplate'
|
||||
import { serverURL } from '../../atoms/settings'
|
||||
import { useToast } from '../../hooks/toast'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
import { ffetch } from '../../lib/httpClient'
|
||||
import { Subscription } from '../../services/subscriptions'
|
||||
import ExtraDownloadOptions from '../ExtraDownloadOptions'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
children: React.ReactElement
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
return <Slide direction="up" ref={ref} {...props} />
|
||||
})
|
||||
|
||||
const SubscriptionsDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||
const [subscriptionURL, setSubscriptionURL] = useState('')
|
||||
const [subscriptionCron, setSubscriptionCron] = useState('')
|
||||
|
||||
const customArgs = useAtomValue(customArgsState)
|
||||
|
||||
const { i18n } = useI18n()
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const submit = async (sub: Omit<Subscription, 'id'>) => {
|
||||
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(sub)
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => onClose()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<AppBar sx={{ position: 'relative' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={onClose}
|
||||
aria-label="close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
{i18n.t('subscriptionsButtonLabel')}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box sx={{
|
||||
backgroundColor: (theme) => theme.palette.background.default,
|
||||
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||
}}>
|
||||
<Container sx={{ my: 4 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Grid container gap={1.5}>
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
{i18n.t('subscriptionsInfo')}
|
||||
</Alert>
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
{i18n.t('livestreamExperimentalWarning')}
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid item xs={12} mt={1}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('subscriptionsURLInput')}
|
||||
variant="outlined"
|
||||
placeholder="https://www.youtube.com/@SomeChannelThatExists/videos"
|
||||
onChange={(e) => setSubscriptionURL(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={8} mt={-2}>
|
||||
<ExtraDownloadOptions />
|
||||
</Grid>
|
||||
<Grid item xs={3.871}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('cronExpressionLabel')}
|
||||
variant="outlined"
|
||||
placeholder="*/5 * * * *"
|
||||
onChange={(e) => setSubscriptionCron(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
variant="contained"
|
||||
disabled={subscriptionURL === ''}
|
||||
onClick={() => startTransition(() => submit({
|
||||
url: subscriptionURL,
|
||||
params: customArgs,
|
||||
cron_expression: subscriptionCron
|
||||
}))}
|
||||
>
|
||||
{i18n.t('startButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionsDialog
|
||||
@@ -0,0 +1,162 @@
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Dialog,
|
||||
Grid,
|
||||
IconButton,
|
||||
Paper,
|
||||
Slide,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { forwardRef, startTransition, useState } from 'react'
|
||||
import { customArgsState } from '../../atoms/downloadTemplate'
|
||||
import { serverURL } from '../../atoms/settings'
|
||||
import { useToast } from '../../hooks/toast'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
import { ffetch } from '../../lib/httpClient'
|
||||
import { Subscription } from '../../services/subscriptions'
|
||||
import ExtraDownloadOptions from '../ExtraDownloadOptions'
|
||||
|
||||
type Props = {
|
||||
subscription: Subscription | undefined
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
children: React.ReactElement
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
return <Slide direction="up" ref={ref} {...props} />
|
||||
})
|
||||
|
||||
const SubscriptionsEditDialog: React.FC<Props> = ({ subscription, onClose }) => {
|
||||
const [subscriptionURL, setSubscriptionURL] = useState('')
|
||||
const [subscriptionCron, setSubscriptionCron] = useState('')
|
||||
|
||||
const customArgs = useAtomValue(customArgsState)
|
||||
|
||||
const { i18n } = useI18n()
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const editSubscription = async (sub: Subscription) => {
|
||||
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(sub)
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => onClose()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={!!subscription}
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<AppBar sx={{ position: 'relative' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => onClose()}
|
||||
aria-label="close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
{i18n.t('subscriptionsButtonLabel')}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box sx={{
|
||||
backgroundColor: (theme) => theme.palette.background.default,
|
||||
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||
}}>
|
||||
<Container sx={{ my: 4 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Grid container gap={1.5}>
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
Editing {subscription?.url}
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid item xs={12} mt={1}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('subscriptionsURLInput')}
|
||||
variant="outlined"
|
||||
defaultValue={subscription?.url}
|
||||
placeholder="https://www.youtube.com/@SomeChannelThatExists/videos"
|
||||
onChange={(e) => setSubscriptionURL(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={8} mt={-2}>
|
||||
<ExtraDownloadOptions />
|
||||
</Grid>
|
||||
<Grid item xs={3.871}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('cronExpressionLabel')}
|
||||
variant="outlined"
|
||||
placeholder="*/5 * * * *"
|
||||
defaultValue={subscription?.cron_expression}
|
||||
onChange={(e) => setSubscriptionCron(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
variant="contained"
|
||||
onClick={() => startTransition(async () => await editSubscription({
|
||||
id: subscription?.id ?? '',
|
||||
url: subscriptionURL || subscription?.url!,
|
||||
params: customArgs || subscription?.params!,
|
||||
cron_expression: subscriptionCron || subscription?.cron_expression!
|
||||
}))}
|
||||
>
|
||||
{i18n.t('editButtonLabel')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionsEditDialog
|
||||
@@ -0,0 +1,27 @@
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
|
||||
type Props = {
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
const SubscriptionsSpeedDial: React.FC<Props> = ({ onOpen }) => {
|
||||
const { i18n } = useI18n()
|
||||
|
||||
return (
|
||||
<SpeedDial
|
||||
ariaLabel="Subscriptions speed dial"
|
||||
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||
icon={<SpeedDialIcon />}
|
||||
>
|
||||
<SpeedDialAction
|
||||
icon={<AddCircleIcon />}
|
||||
tooltipTitle={i18n.t('newSubscriptionButton')}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
</SpeedDial>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionsSpeedDial
|
||||
140
frontend/src/components/twitch/TwitchDialog.tsx
Normal file
140
frontend/src/components/twitch/TwitchDialog.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Dialog,
|
||||
Grid,
|
||||
IconButton,
|
||||
Paper,
|
||||
Slide,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { forwardRef, startTransition, useState } from 'react'
|
||||
import { serverURL } from '../../atoms/settings'
|
||||
import { useToast } from '../../hooks/toast'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
import { ffetch } from '../../lib/httpClient'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
children: React.ReactElement
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
return <Slide direction="up" ref={ref} {...props} />
|
||||
})
|
||||
|
||||
const TwitchDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||
const [channelURL, setChannelURL] = useState('')
|
||||
|
||||
const { i18n } = useI18n()
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const submit = async (channelURL: string) => {
|
||||
const task = ffetch<void>(`${baseURL}/twitch/user`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user: channelURL.split('/').at(-1)
|
||||
})
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => onClose()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<AppBar sx={{ position: 'relative' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={onClose}
|
||||
aria-label="close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
{i18n.t('subscriptionsButtonLabel')}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box sx={{
|
||||
backgroundColor: (theme) => theme.palette.background.default,
|
||||
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||
}}>
|
||||
<Container sx={{ my: 4 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Grid container gap={1.5}>
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
{i18n.t('twitchIntegrationInfo')}
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid item xs={12} mt={1}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('subscriptionsURLInput')}
|
||||
variant="outlined"
|
||||
placeholder="https://www.twitch.tv/a_twitch_user_that_exists"
|
||||
onChange={(e) => setChannelURL(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
variant="contained"
|
||||
disabled={channelURL === ''}
|
||||
onClick={() => startTransition(() => submit(channelURL))}
|
||||
>
|
||||
{i18n.t('startButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default TwitchDialog
|
||||
35
frontend/src/hooks/useFetch.ts
Normal file
35
frontend/src/hooks/useFetch.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { useToast } from './toast'
|
||||
|
||||
const useFetch = <R>(resource: string) => {
|
||||
const base = useAtomValue(serverURL)
|
||||
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const [data, setData] = useState<R>()
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const fetcher = () => pipe(
|
||||
ffetch<R>(`${base}${resource}`),
|
||||
matchW(
|
||||
(l) => {
|
||||
setError(l)
|
||||
pushMessage(l, 'error')
|
||||
},
|
||||
(r) => setData(r)
|
||||
)
|
||||
)()
|
||||
|
||||
useEffect(() => {
|
||||
fetcher()
|
||||
}, [])
|
||||
|
||||
return { data, error, fetcher }
|
||||
}
|
||||
|
||||
export default useFetch
|
||||
@@ -1,12 +1,9 @@
|
||||
import { tryCatch } from 'fp-ts/TaskEither'
|
||||
import * as J from 'fp-ts/Json'
|
||||
import * as E from 'fp-ts/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
|
||||
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
||||
() => fetcher<T>(url, opt),
|
||||
(e) => `error while fetching: ${e}`
|
||||
)
|
||||
|
||||
|
||||
const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
||||
async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise<string> {
|
||||
const jwt = localStorage.getItem('token')
|
||||
|
||||
if (opt && !opt.headers) {
|
||||
@@ -20,11 +17,27 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
||||
headers: {
|
||||
...opt?.headers,
|
||||
'X-Authentication': jwt ?? ''
|
||||
}
|
||||
},
|
||||
signal: controller?.signal
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw await res.text()
|
||||
}
|
||||
return res.json() as T
|
||||
}
|
||||
|
||||
|
||||
|
||||
return res.text()
|
||||
}
|
||||
|
||||
export const ffetch = <T>(url: string, opt?: RequestInit, controller?: AbortController) => tryCatch(
|
||||
async () => pipe(
|
||||
await fetcher(url, opt, controller),
|
||||
J.parse,
|
||||
E.match(
|
||||
(l) => l as T,
|
||||
(r) => r as T
|
||||
)
|
||||
),
|
||||
(e) => `error while fetching: ${e}`
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ export default class Translator {
|
||||
t(key: string): string {
|
||||
if (this.current) {
|
||||
//@ts-ignore
|
||||
return this.current[key] ?? fallback.keys[key]
|
||||
return this.current[key] ?? fallback.keys[key] ?? 'caption not defined'
|
||||
}
|
||||
return 'caption not defined'
|
||||
}
|
||||
|
||||
@@ -42,10 +42,12 @@ export class RPCClient {
|
||||
}
|
||||
|
||||
private argsSanitizer(args: string): string[] {
|
||||
const splitOnlyWhitespaces = /[^\s"']+|"([^"]*)"|'([^']*)'/gm
|
||||
|
||||
return args
|
||||
.split(' ')
|
||||
.map(a => a.trim().replaceAll('"', ''))
|
||||
.filter(Boolean)
|
||||
.match(splitOnlyWhitespaces)
|
||||
?.map(a => a.trim())
|
||||
.filter(Boolean) ?? []
|
||||
}
|
||||
|
||||
private async sendHTTP<T>(req: RPCRequest) {
|
||||
@@ -198,4 +200,11 @@ export class RPCClient {
|
||||
params: []
|
||||
})
|
||||
}
|
||||
|
||||
public clearCompleted() {
|
||||
return this.sendHTTP({
|
||||
method: 'Service.ClearCompleted',
|
||||
params: []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import Terminal from './views/Terminal'
|
||||
|
||||
const Home = lazy(() => import('./views/Home'))
|
||||
const Login = lazy(() => import('./views/Login'))
|
||||
const Twitch = lazy(() => import('./views/Twitch'))
|
||||
const Archive = lazy(() => import('./views/Archive'))
|
||||
const Settings = lazy(() => import('./views/Settings'))
|
||||
const LiveStream = lazy(() => import('./views/Livestream'))
|
||||
const Filebrowser = lazy(() => import('./views/Filebrowser'))
|
||||
const Subscriptions = lazy(() => import('./views/Subscriptions'))
|
||||
|
||||
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
||||
|
||||
@@ -73,6 +75,19 @@ export const router = createHashRouter([
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/subscriptions',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Subscriptions />
|
||||
</Suspense >
|
||||
),
|
||||
errorElement: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<ErrorBoundary />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: (
|
||||
@@ -97,6 +112,14 @@ export const router = createHashRouter([
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/twitch',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Twitch />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
]
|
||||
},
|
||||
])
|
||||
37
frontend/src/services/subscriptions.ts
Normal file
37
frontend/src/services/subscriptions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// import { PaginatedResponse } from '../types'
|
||||
|
||||
export type Subscription = {
|
||||
id: string
|
||||
url: string
|
||||
params: string
|
||||
cron_expression: string
|
||||
}
|
||||
|
||||
// class SubscriptionService {
|
||||
// private _baseURL: string = ''
|
||||
|
||||
// public set baseURL(v: string) {
|
||||
// this._baseURL = v
|
||||
// }
|
||||
|
||||
// public async delete(id: string): Promise<void> {
|
||||
|
||||
// }
|
||||
|
||||
// public async listPaginated(start: number, limit: number = 50): Promise<PaginatedResponse<Subscription[]>> {
|
||||
// const res = await fetch(`${this._baseURL}/subscriptions?id=${start}&limit=${limit}`)
|
||||
// const data: PaginatedResponse<Subscription[]> = await res.json()
|
||||
|
||||
// return data
|
||||
// }
|
||||
|
||||
// public async submit(sub: Subscription): Promise<void> {
|
||||
|
||||
// }
|
||||
|
||||
// public async edit(sub: Subscription): Promise<void> {
|
||||
|
||||
// }
|
||||
// }
|
||||
|
||||
// export default SubscriptionService
|
||||
@@ -13,6 +13,7 @@ export type RPCMethods =
|
||||
| "Service.ProgressLivestream"
|
||||
| "Service.KillLivestream"
|
||||
| "Service.KillAllLivestream"
|
||||
| "Service.ClearCompleted"
|
||||
|
||||
export type RPCRequest = {
|
||||
method: RPCMethods
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
appTitleState,
|
||||
enableCustomArgsState,
|
||||
fileRenamingState,
|
||||
autoFileExtensionState,
|
||||
formatSelectionState,
|
||||
languageState,
|
||||
languages,
|
||||
@@ -61,6 +62,7 @@ export default function Settings() {
|
||||
const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
|
||||
const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
|
||||
const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
|
||||
const [autoFileExtension, setAutoFileExtension] = useAtom(autoFileExtensionState)
|
||||
const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
|
||||
|
||||
const [serverAddr, setServerAddr] = useAtom(serverAddressState)
|
||||
@@ -343,12 +345,30 @@ export default function Settings() {
|
||||
<Switch
|
||||
defaultChecked={fileRenaming}
|
||||
onChange={() => {
|
||||
if (fileRenaming) {
|
||||
setAutoFileExtension(false)
|
||||
}
|
||||
setFileRenaming(state => !state)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={i18n.t('filenameOverrideOption')}
|
||||
/>
|
||||
{
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
disabled={!fileRenaming}
|
||||
checked={fileRenaming ? autoFileExtension : false}
|
||||
defaultChecked={autoFileExtension}
|
||||
onChange={() => {
|
||||
setAutoFileExtension(state => !state)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={i18n.t('autoFileExtensionOption')}
|
||||
/>
|
||||
}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
|
||||
157
frontend/src/views/Subscriptions.tsx
Normal file
157
frontend/src/views/Subscriptions.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Table, TableBody, TableCell, TableContainer,
|
||||
TableHead, TablePagination, TableRow
|
||||
} from '@mui/material'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||
import SubscriptionsDialog from '../components/subscriptions/SubscriptionsDialog'
|
||||
import SubscriptionsEditDialog from '../components/subscriptions/SubscriptionsEditDialog'
|
||||
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import useFetch from '../hooks/useFetch'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { Subscription } from '../services/subscriptions'
|
||||
import { PaginatedResponse } from '../types'
|
||||
|
||||
const SubscriptionsView: React.FC = () => {
|
||||
const { i18n } = useI18n()
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const [selectedSubscription, setSelectedSubscription] = useState<Subscription>()
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
|
||||
const [startId, setStartId] = useState(0)
|
||||
const [limit, setLimit] = useState(9)
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const { data: subs, fetcher: refecth } = useFetch<PaginatedResponse<Subscription[]>>(
|
||||
`/subscriptions?id=${startId}&limit=${limit}`
|
||||
)
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const deleteSubscription = async (id: string) => {
|
||||
const task = ffetch<void>(`${baseURL}/subscriptions/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
() => refecth()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingBackdrop isLoading={!subs || isPending} />
|
||||
|
||||
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||
|
||||
<SubscriptionsEditDialog
|
||||
subscription={selectedSubscription}
|
||||
onClose={() => {
|
||||
setSelectedSubscription(undefined)
|
||||
refecth()
|
||||
}}
|
||||
/>
|
||||
<SubscriptionsDialog open={openDialog} onClose={() => {
|
||||
setOpenDialog(s => !s)
|
||||
refecth()
|
||||
}} />
|
||||
|
||||
{!subs || subs.data.length === 0 ?
|
||||
<NoSubscriptions /> :
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||
<Paper sx={{
|
||||
p: 2.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '80vh',
|
||||
}}>
|
||||
<TableContainer component={Box}>
|
||||
<Table sx={{ minWidth: '100%' }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="left">URL</TableCell>
|
||||
<TableCell align="right">Params</TableCell>
|
||||
<TableCell align="right">{i18n.t('cronExpressionLabel')}</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody sx={{ mb: 'auto' }}>
|
||||
{subs.data.map(x => (
|
||||
<TableRow
|
||||
key={x.id}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell>{x.url}</TableCell>
|
||||
<TableCell align='right'>
|
||||
{x.params}
|
||||
</TableCell>
|
||||
<TableCell align='right'>
|
||||
{x.cron_expression}
|
||||
</TableCell>
|
||||
<TableCell align='center'>
|
||||
<Button
|
||||
variant='contained'
|
||||
size='small'
|
||||
sx={{ mr: 0.5 }}
|
||||
onClick={() => setSelectedSubscription(x)}
|
||||
>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant='contained'
|
||||
size='small'
|
||||
onClick={() => startTransition(async () => await deleteSubscription(x.id))}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={-1}
|
||||
page={page}
|
||||
onPageChange={(_, p) => {
|
||||
if (p < page) {
|
||||
setPage(s => (s - 1 <= 0 ? 0 : s - 1))
|
||||
setStartId(subs.first)
|
||||
return
|
||||
}
|
||||
setPage(s => s + 1)
|
||||
setStartId(subs.next)
|
||||
}}
|
||||
rowsPerPage={limit}
|
||||
rowsPerPageOptions={[9, 10, 25, 50, 100]}
|
||||
onRowsPerPageChange={(e) => { setLimit(parseInt(e.target.value)) }}
|
||||
/>
|
||||
</Paper>
|
||||
</Container>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionsView
|
||||
77
frontend/src/views/Twitch.tsx
Normal file
77
frontend/src/views/Twitch.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Chip,
|
||||
Container,
|
||||
Paper
|
||||
} from '@mui/material'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||
import TwitchDialog from '../components/twitch/TwitchDialog'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import useFetch from '../hooks/useFetch'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
|
||||
const TwitchView: React.FC = () => {
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
|
||||
const { data: users, fetcher: refetch } = useFetch<Array<string>>('/twitch/users')
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const deleteUser = async (user: string) => {
|
||||
const task = ffetch<void>(`${baseURL}/twitch/user/${user}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
() => refetch()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingBackdrop isLoading={!users || isPending} />
|
||||
|
||||
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||
|
||||
<TwitchDialog open={openDialog} onClose={() => {
|
||||
setOpenDialog(s => !s)
|
||||
refetch()
|
||||
}} />
|
||||
|
||||
{
|
||||
!users || users.length === 0 ?
|
||||
<NoSubscriptions /> :
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||
<Paper sx={{
|
||||
p: 2.5,
|
||||
minHeight: '80vh',
|
||||
}}>
|
||||
{users.map(user => (
|
||||
<Chip
|
||||
label={user}
|
||||
onDelete={() => startTransition(async () => await deleteUser(user))}
|
||||
/>
|
||||
))}
|
||||
</Paper>
|
||||
</Container>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TwitchView
|
||||
31
go.mod
31
go.mod
@@ -1,37 +1,32 @@
|
||||
module github.com/marcopiovanello/yt-dlp-web-ui/v3
|
||||
|
||||
go 1.23
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.12.0
|
||||
github.com/go-chi/chi/v5 v5.2.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/sync v0.8.0
|
||||
golang.org/x/sys v0.25.0
|
||||
github.com/robfig/cron/v3 v3.0.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
golang.org/x/sync v0.10.0
|
||||
golang.org/x/sys v0.29.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.33.1
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
||||
modernc.org/libc v1.61.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||
modernc.org/libc v1.61.11 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
)
|
||||
|
||||
90
go.sum
90
go.sum
@@ -1,13 +1,13 @@
|
||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
|
||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
||||
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
|
||||
@@ -22,13 +22,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
@@ -37,51 +30,50 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
|
||||
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
||||
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.15 h1:wFDan71KnYqeHz4eF63vmGE6Q6Pc0PUGDpP0PRMYjDc=
|
||||
modernc.org/ccgo/v4 v4.23.15/go.mod h1:nJX30dks/IWuBOnVa7VRii9Me4/9TZ1SC9GNtmARTy0=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/gc/v2 v2.6.2 h1:YBXi5Kqp6aCK3fIxwKQ3/fErvawVKwjOLItxj1brGds=
|
||||
modernc.org/gc/v2 v2.6.2/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.61.11 h1:6sZG8uB6EMMG7iTLPTndi8jyTdgAQNIeLGjCFICACZw=
|
||||
modernc.org/libc v1.61.11/go.mod h1:HHX+srFdn839oaJRd0W8hBM3eg+mieyZCAjWwB08/nM=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -4,32 +4,39 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
LogPath string `yaml:"log_path"`
|
||||
EnableFileLogging bool `yaml:"enable_file_logging"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
DownloadPath string `yaml:"downloadPath"`
|
||||
DownloaderPath string `yaml:"downloaderPath"`
|
||||
RequireAuth bool `yaml:"require_auth"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
QueueSize int `yaml:"queue_size"`
|
||||
LocalDatabasePath string `yaml:"local_database_path"`
|
||||
SessionFilePath string `yaml:"session_file_path"`
|
||||
path string // private
|
||||
UseOpenId bool `yaml:"use_openid"`
|
||||
OpenIdProviderURL string `yaml:"openid_provider_url"`
|
||||
OpenIdClientId string `yaml:"openid_client_id"`
|
||||
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
||||
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
||||
FrontendPath string `yaml:"frontend_path"`
|
||||
AutoArchive bool `yaml:"auto_archive"`
|
||||
LogPath string `yaml:"log_path"`
|
||||
EnableFileLogging bool `yaml:"enable_file_logging"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
DownloadPath string `yaml:"downloadPath"`
|
||||
DownloaderPath string `yaml:"downloaderPath"`
|
||||
RequireAuth bool `yaml:"require_auth"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
QueueSize int `yaml:"queue_size"`
|
||||
LocalDatabasePath string `yaml:"local_database_path"`
|
||||
SessionFilePath string `yaml:"session_file_path"`
|
||||
path string // private
|
||||
UseOpenId bool `yaml:"use_openid"`
|
||||
OpenIdProviderURL string `yaml:"openid_provider_url"`
|
||||
OpenIdClientId string `yaml:"openid_client_id"`
|
||||
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
||||
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
||||
OpenIdEmailWhitelist []string `yaml:"openid_email_whitelist"`
|
||||
FrontendPath string `yaml:"frontend_path"`
|
||||
AutoArchive bool `yaml:"auto_archive"`
|
||||
Twitch struct {
|
||||
ClientId string `yaml:"client_id"`
|
||||
ClientSecret string `yaml:"client_secret"`
|
||||
CheckInterval time.Duration `yaml:"check_interval"`
|
||||
} `yaml:"twitch"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -41,6 +48,7 @@ func Instance() *Config {
|
||||
if instance == nil {
|
||||
instanceOnce.Do(func() {
|
||||
instance = &Config{}
|
||||
instance.Twitch.CheckInterval = time.Minute * 5
|
||||
})
|
||||
}
|
||||
return instance
|
||||
|
||||
@@ -49,6 +49,18 @@ func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
ctx,
|
||||
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
url VARCHAR(2048) UNIQUE NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
cron TEXT
|
||||
)`,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lockFileExists() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package filebrowser
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
@@ -2,6 +2,7 @@ package internal
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type LoadBalancer struct {
|
||||
@@ -9,7 +10,29 @@ type LoadBalancer struct {
|
||||
done chan *Worker
|
||||
}
|
||||
|
||||
func (b *LoadBalancer) Balance(work chan Process) {
|
||||
func NewLoadBalancer(numWorker int) *LoadBalancer {
|
||||
var pool Pool
|
||||
|
||||
doneChan := make(chan *Worker)
|
||||
|
||||
for i := range numWorker {
|
||||
w := &Worker{
|
||||
requests: make(chan *Process, 1),
|
||||
index: i,
|
||||
}
|
||||
go w.Work(doneChan)
|
||||
pool = append(pool, w)
|
||||
|
||||
slog.Info("spawned worker", slog.Int("index", i))
|
||||
}
|
||||
|
||||
return &LoadBalancer{
|
||||
pool: pool,
|
||||
done: doneChan,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LoadBalancer) Balance(work chan *Process) {
|
||||
for {
|
||||
select {
|
||||
case req := <-work:
|
||||
@@ -20,7 +43,7 @@ func (b *LoadBalancer) Balance(work chan Process) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LoadBalancer) dispatch(req Process) {
|
||||
func (b *LoadBalancer) dispatch(req *Process) {
|
||||
w := heap.Pop(&b.pool).(*Worker)
|
||||
w.requests <- req
|
||||
w.pending++
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package internal
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
|
||||
)
|
||||
|
||||
// Used to unmarshall yt-dlp progress
|
||||
type ProgressTemplate struct {
|
||||
@@ -29,29 +31,14 @@ type DownloadProgress struct {
|
||||
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
|
||||
// as JSON-RPC result field
|
||||
type ProcessResponse struct {
|
||||
Id string `json:"id"`
|
||||
Progress DownloadProgress `json:"progress"`
|
||||
Info DownloadInfo `json:"info"`
|
||||
Output DownloadOutput `json:"output"`
|
||||
Params []string `json:"params"`
|
||||
Id string `json:"id"`
|
||||
Progress DownloadProgress `json:"progress"`
|
||||
Info common.DownloadInfo `json:"info"`
|
||||
Output DownloadOutput `json:"output"`
|
||||
Params []string `json:"params"`
|
||||
}
|
||||
|
||||
// struct representing the current status of the memoryDB
|
||||
|
||||
@@ -141,26 +141,13 @@ func (l *LiveStream) monitorStartTime(r io.Reader) {
|
||||
}
|
||||
}
|
||||
|
||||
const TRIES = 5
|
||||
/*
|
||||
if it's waiting a livestream the 5th line will indicate the time to live
|
||||
its a dumb and not robust method.
|
||||
scanner.Scan()
|
||||
|
||||
example:
|
||||
[youtube] Extracting URL: https://www.youtube.com/watch?v=IQVbGfVVjgY
|
||||
[youtube] IQVbGfVVjgY: Downloading webpage
|
||||
[youtube] IQVbGfVVjgY: Downloading ios player API JSON
|
||||
[youtube] IQVbGfVVjgY: Downloading web creator player API JSON
|
||||
WARNING: [youtube] This live event will begin in 27 minutes. <- STDERR, ignore
|
||||
[wait] Waiting for 00:27:15 - Press Ctrl+C to try now <- 5th line
|
||||
*/
|
||||
for range TRIES {
|
||||
for !strings.Contains(scanner.Text(), "Waiting for") {
|
||||
scanner.Scan()
|
||||
|
||||
if strings.Contains(scanner.Text(), "Waiting for") {
|
||||
waitTimeScanner()
|
||||
}
|
||||
}
|
||||
|
||||
waitTimeScanner()
|
||||
}
|
||||
|
||||
func (l *LiveStream) WaitTime() <-chan time.Duration {
|
||||
|
||||
@@ -9,15 +9,17 @@ import (
|
||||
)
|
||||
|
||||
func setupTest() {
|
||||
config.Instance().DownloaderPath = "yt-dlp"
|
||||
config.Instance().DownloaderPath = "build/yt-dlp"
|
||||
}
|
||||
|
||||
const URL = "https://www.youtube.com/watch?v=pwoAyLGOysU"
|
||||
|
||||
func TestLivestream(t *testing.T) {
|
||||
setupTest()
|
||||
|
||||
done := make(chan *LiveStream)
|
||||
|
||||
ls := New("https://www.youtube.com/watch?v=LSm1daKezcE", done, &internal.MessageQueue{}, &internal.MemoryDB{})
|
||||
ls := New(URL, done, &internal.MessageQueue{}, &internal.MemoryDB{})
|
||||
go ls.Start()
|
||||
|
||||
time.AfterFunc(time.Second*20, func() {
|
||||
|
||||
@@ -76,7 +76,7 @@ func (m *Monitor) Status() LiveStreamStatus {
|
||||
// Persist the monitor current state to a file.
|
||||
// The file is located in the configured config directory
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (m *Monitor) Persist() error {
|
||||
|
||||
// Restore a saved state and resume the monitored livestreams
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package internal
|
||||
import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||
)
|
||||
|
||||
var memDbEvents = make(chan *Process)
|
||||
|
||||
// In-Memory Thread-Safe Key-Value Storage with optional persistence
|
||||
type MemoryDB struct {
|
||||
table map[string]*Process
|
||||
@@ -144,3 +147,12 @@ func (m *MemoryDB) Restore(mq *MessageQueue) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryDB) EventListener() {
|
||||
for p := range memDbEvents {
|
||||
if p.AutoRemove {
|
||||
slog.Info("compacting MemoryDB", slog.String("id", p.Id))
|
||||
m.Delete(p.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,20 +9,18 @@ import (
|
||||
"strings"
|
||||
"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/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 {
|
||||
params := append(req.Params, "--flat-playlist", "-J")
|
||||
urlWithParams := append([]string{req.URL}, params...)
|
||||
|
||||
var (
|
||||
downloader = config.Instance().DownloaderPath
|
||||
cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J")
|
||||
cmd = exec.Command(downloader, urlWithParams...)
|
||||
)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
@@ -30,7 +28,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var m metadata
|
||||
var m playlist.Metadata
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
@@ -52,13 +50,21 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||
return errors.New("probably not a valid URL")
|
||||
}
|
||||
|
||||
if m.Type == "playlist" {
|
||||
entries := slices.CompactFunc(slices.Compact(m.Entries), func(a DownloadInfo, b DownloadInfo) bool {
|
||||
if m.IsPlaylist() {
|
||||
entries := slices.CompactFunc(slices.Compact(m.Entries), func(a common.DownloadInfo, b common.DownloadInfo) bool {
|
||||
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)))
|
||||
|
||||
if err := playlist.ApplyModifiers(&entries, req.Params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, meta := range entries {
|
||||
// detect playlist title from metadata since each playlist entry will be
|
||||
// treated as an individual download
|
||||
@@ -82,11 +88,13 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||
|
||||
proc.Info.URL = meta.URL
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
db.Set(proc)
|
||||
mq.Publish(proc)
|
||||
|
||||
proc.Info.CreatedAt = meta.CreatedAt
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
proc := &Process{
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
package internal
|
||||
|
||||
// Pool implements heap.Interface interface as a standard priority queue
|
||||
type Pool []*Worker
|
||||
|
||||
func (h Pool) Len() int { return len(h) }
|
||||
func (h Pool) Less(i, j int) bool { return h[i].index < h[j].index }
|
||||
func (h Pool) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) }
|
||||
func (h Pool) Less(i, j int) bool { return h[i].pending < h[j].pending }
|
||||
|
||||
func (h Pool) Swap(i, j int) {
|
||||
h[i], h[j] = h[j], h[i]
|
||||
h[i].index = i
|
||||
h[j].index = j
|
||||
}
|
||||
|
||||
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) }
|
||||
|
||||
func (h *Pool) Pop() any {
|
||||
old := *h
|
||||
n := len(old)
|
||||
x := old[n-1]
|
||||
old[n-1] = nil
|
||||
*h = old[0 : n-1]
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -48,8 +49,9 @@ type Process struct {
|
||||
Id string
|
||||
Url string
|
||||
Livestream bool
|
||||
AutoRemove bool
|
||||
Params []string
|
||||
Info DownloadInfo
|
||||
Info common.DownloadInfo
|
||||
Progress DownloadProgress
|
||||
Output DownloadOutput
|
||||
proc *os.Process
|
||||
@@ -98,6 +100,7 @@ func (p *Process) Start() {
|
||||
templateReplacer.Replace(downloadTemplate),
|
||||
"--progress-template",
|
||||
templateReplacer.Replace(postprocessTemplate),
|
||||
"--no-exec",
|
||||
}
|
||||
|
||||
// if user asked to manually override the output path...
|
||||
@@ -253,6 +256,8 @@ func (p *Process) Complete() {
|
||||
slog.String("id", p.getShortId()),
|
||||
slog.String("url", p.Url),
|
||||
)
|
||||
|
||||
memDbEvents <- p
|
||||
}
|
||||
|
||||
// Kill a process and remove it from the memory
|
||||
@@ -299,7 +304,7 @@ func (p *Process) GetFileName(o *DownloadOutput) error {
|
||||
|
||||
func (p *Process) SetPending() {
|
||||
// Since video's title isn't available yet, fill in with the URL.
|
||||
p.Info = DownloadInfo{
|
||||
p.Info = common.DownloadInfo{
|
||||
URL: p.Url,
|
||||
Title: p.Url,
|
||||
CreatedAt: time.Now(),
|
||||
@@ -331,7 +336,7 @@ func (p *Process) SetMetadata() error {
|
||||
return err
|
||||
}
|
||||
|
||||
info := DownloadInfo{
|
||||
info := common.DownloadInfo{
|
||||
URL: p.Url,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package internal
|
||||
|
||||
type Worker struct {
|
||||
requests chan Process // downloads to do
|
||||
pending int // downloads pending
|
||||
index int // index in the heap
|
||||
requests chan *Process // downloads to do
|
||||
pending int // downloads pending
|
||||
index int // index in the heap
|
||||
}
|
||||
|
||||
func (w *Worker) Work(done chan *Worker) {
|
||||
|
||||
21
server/middleware/utils.go
Normal file
21
server/middleware/utils.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
|
||||
)
|
||||
|
||||
func ApplyAuthenticationByConfig(next http.Handler) http.Handler {
|
||||
handler := next
|
||||
|
||||
if config.Instance().RequireAuth {
|
||||
handler = Authenticated(handler)
|
||||
}
|
||||
if config.Instance().UseOpenId {
|
||||
handler = openid.Middleware(handler)
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -76,6 +78,21 @@ func doAuthentification(r *http.Request, setCookieCallback func(t *oauth2.Token)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"email_verified"`
|
||||
}
|
||||
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
whitelist := config.Instance().OpenIdEmailWhitelist
|
||||
|
||||
if len(whitelist) > 0 && !slices.Contains(whitelist, claims.Email) {
|
||||
return nil, errors.New("email address not found in ACL")
|
||||
}
|
||||
|
||||
nonce, err := r.Cookie("nonce")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
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 }
|
||||
12
server/playlist/types.go
Normal file
12
server/playlist/types.go
Normal file
@@ -0,0 +1,12 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
func (m *Metadata) IsPlaylist() bool { return m.Type == "playlist" }
|
||||
@@ -164,7 +164,7 @@ func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
|
||||
|
||||
func (s *Service) GetVersion(ctx context.Context) (string, string, error) {
|
||||
//TODO: load from realease properties file, or anything else outside code
|
||||
const CURRENT_RPC_VERSION = "3.2.3"
|
||||
const CURRENT_RPC_VERSION = "3.2.6"
|
||||
|
||||
result := make(chan string, 1)
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
|
||||
}
|
||||
|
||||
slog.Info("succesfully killed process", slog.String("id", proc.Id))
|
||||
proc = nil // gc helper
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -195,6 +196,35 @@ func (s *Service) Clear(args string, killed *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Removes completed processes
|
||||
func (s *Service) ClearCompleted(cleared *string) error {
|
||||
var (
|
||||
keys = s.db.Keys()
|
||||
removeFunc = func(p *internal.Process) error {
|
||||
defer s.db.Delete(p.Id)
|
||||
|
||||
if p.Progress.Status != internal.StatusCompleted {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.Kill()
|
||||
}
|
||||
)
|
||||
|
||||
for _, key := range *keys {
|
||||
proc, err := s.db.Get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := removeFunc(proc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FreeSpace gets the available from package sys util
|
||||
func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
|
||||
freeSpace, err := sys.FreeSpace()
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"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/dbutil"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/handlers"
|
||||
"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/livestream"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/logging"
|
||||
@@ -32,6 +32,10 @@ import (
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/rest"
|
||||
ytdlpRPC "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/rpc"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/twitch"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/user"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -48,6 +52,7 @@ type serverConfig struct {
|
||||
db *sql.DB
|
||||
mq *internal.MessageQueue
|
||||
lm *livestream.Monitor
|
||||
tm *twitch.Monitor
|
||||
}
|
||||
|
||||
// TODO: change scope
|
||||
@@ -106,22 +111,39 @@ func RunBlocking(rc *RunConfig) {
|
||||
}
|
||||
mq.SetupConsumers()
|
||||
go mdb.Restore(mq)
|
||||
go mdb.EventListener()
|
||||
|
||||
lm := livestream.NewMonitor(mq, mdb)
|
||||
go lm.Schedule()
|
||||
go lm.Restore()
|
||||
|
||||
srv := newServer(serverConfig{
|
||||
tm := twitch.NewMonitor(
|
||||
twitch.NewAuthenticationManager(
|
||||
config.Instance().Twitch.ClientId,
|
||||
config.Instance().Twitch.ClientSecret,
|
||||
),
|
||||
)
|
||||
go tm.Monitor(
|
||||
context.TODO(),
|
||||
config.Instance().Twitch.CheckInterval,
|
||||
twitch.DEFAULT_DOWNLOAD_HANDLER(mdb, mq),
|
||||
)
|
||||
go tm.Restore()
|
||||
|
||||
scfg := serverConfig{
|
||||
frontend: rc.App,
|
||||
swagger: rc.Swagger,
|
||||
mdb: mdb,
|
||||
mq: mq,
|
||||
db: db,
|
||||
lm: lm,
|
||||
})
|
||||
tm: tm,
|
||||
}
|
||||
|
||||
go gracefulShutdown(srv, mdb)
|
||||
go autoPersist(time.Minute*5, mdb, lm)
|
||||
srv := newServer(scfg)
|
||||
|
||||
go gracefulShutdown(srv, &scfg)
|
||||
go autoPersist(time.Minute*5, mdb, lm, tm)
|
||||
|
||||
var (
|
||||
network = "tcp"
|
||||
@@ -150,6 +172,9 @@ func RunBlocking(rc *RunConfig) {
|
||||
func newServer(c serverConfig) *http.Server {
|
||||
archiver.Register(c.db)
|
||||
|
||||
cronTaskRunner := task.NewCronTaskRunner(c.mq, c.mdb)
|
||||
go cronTaskRunner.Spawner(context.TODO())
|
||||
|
||||
service := ytdlpRPC.Container(c.mdb, c.mq, c.lm)
|
||||
rpc.Register(service)
|
||||
|
||||
@@ -181,17 +206,12 @@ func newServer(c serverConfig) *http.Server {
|
||||
|
||||
// Filebrowser routes
|
||||
r.Route("/filebrowser", func(r chi.Router) {
|
||||
if config.Instance().RequireAuth {
|
||||
r.Use(middlewares.Authenticated)
|
||||
}
|
||||
if config.Instance().UseOpenId {
|
||||
r.Use(openid.Middleware)
|
||||
}
|
||||
r.Post("/downloaded", handlers.ListDownloaded)
|
||||
r.Post("/delete", handlers.DeleteFile)
|
||||
r.Get("/d/{id}", handlers.DownloadFile)
|
||||
r.Get("/v/{id}", handlers.SendFile)
|
||||
r.Get("/bulk", handlers.BulkDownload(c.mdb))
|
||||
r.Use(middlewares.ApplyAuthenticationByConfig)
|
||||
r.Post("/downloaded", filebrowser.ListDownloaded)
|
||||
r.Post("/delete", filebrowser.DeleteFile)
|
||||
r.Get("/d/{id}", filebrowser.DownloadFile)
|
||||
r.Get("/v/{id}", filebrowser.SendFile)
|
||||
r.Get("/bulk", filebrowser.BulkDownload(c.mdb))
|
||||
})
|
||||
|
||||
// Archive routes
|
||||
@@ -199,8 +219,8 @@ func newServer(c serverConfig) *http.Server {
|
||||
|
||||
// Authentication routes
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", handlers.Login)
|
||||
r.Get("/logout", handlers.Logout)
|
||||
r.Post("/login", user.Login)
|
||||
r.Get("/logout", user.Logout)
|
||||
|
||||
r.Route("/openid", func(r chi.Router) {
|
||||
r.Get("/login", openid.Login)
|
||||
@@ -225,10 +245,21 @@ func newServer(c serverConfig) *http.Server {
|
||||
// Status
|
||||
r.Route("/status", status.ApplyRouter(c.mdb))
|
||||
|
||||
// Subscriptions
|
||||
r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter())
|
||||
|
||||
// Twitch
|
||||
r.Route("/twitch", func(r chi.Router) {
|
||||
r.Use(middlewares.ApplyAuthenticationByConfig)
|
||||
r.Get("/users", twitch.GetMonitoredUsers(c.tm))
|
||||
r.Post("/user", twitch.MonitorUserHandler(c.tm))
|
||||
r.Delete("/user/{user}", twitch.DeleteUser(c.tm))
|
||||
})
|
||||
|
||||
return &http.Server{Handler: r}
|
||||
}
|
||||
|
||||
func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
|
||||
func gracefulShutdown(srv *http.Server, cfg *serverConfig) {
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
@@ -240,7 +271,9 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
|
||||
slog.Info("shutdown signal received")
|
||||
|
||||
defer func() {
|
||||
db.Persist()
|
||||
cfg.mdb.Persist()
|
||||
cfg.lm.Persist()
|
||||
cfg.tm.Persist()
|
||||
|
||||
stop()
|
||||
srv.Shutdown(context.Background())
|
||||
@@ -248,8 +281,14 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
|
||||
}()
|
||||
}
|
||||
|
||||
func autoPersist(d time.Duration, db *internal.MemoryDB, lm *livestream.Monitor) {
|
||||
func autoPersist(
|
||||
d time.Duration,
|
||||
db *internal.MemoryDB,
|
||||
lm *livestream.Monitor,
|
||||
tm *twitch.Monitor,
|
||||
) {
|
||||
for {
|
||||
time.Sleep(d)
|
||||
if err := db.Persist(); err != nil {
|
||||
slog.Warn("failed to persisted session", slog.Any("err", err))
|
||||
}
|
||||
@@ -257,7 +296,10 @@ func autoPersist(d time.Duration, db *internal.MemoryDB, lm *livestream.Monitor)
|
||||
slog.Warn(
|
||||
"failed to persisted livestreams monitor session", slog.Any("err", err.Error()))
|
||||
}
|
||||
if err := tm.Persist(); err != nil {
|
||||
slog.Warn(
|
||||
"failed to persisted twitch monitor session", slog.Any("err", err.Error()))
|
||||
}
|
||||
slog.Debug("sucessfully persisted session")
|
||||
time.Sleep(d)
|
||||
}
|
||||
}
|
||||
|
||||
17
server/subscription/container.go
Normal file
17
server/subscription/container.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task"
|
||||
)
|
||||
|
||||
func Container(db *sql.DB, runner task.TaskRunner) domain.RestHandler {
|
||||
var (
|
||||
r = provideRepository(db)
|
||||
s = provideService(r, runner)
|
||||
h = provideHandler(s)
|
||||
)
|
||||
return h
|
||||
}
|
||||
8
server/subscription/data/models.go
Normal file
8
server/subscription/data/models.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package data
|
||||
|
||||
type Subscription struct {
|
||||
Id string
|
||||
URL string
|
||||
Params string
|
||||
CronExpr string
|
||||
}
|
||||
47
server/subscription/domain/subscription.go
Normal file
47
server/subscription/domain/subscription.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/data"
|
||||
)
|
||||
|
||||
type Subscription struct {
|
||||
Id string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Params string `json:"params"`
|
||||
CronExpr string `json:"cron_expression"`
|
||||
}
|
||||
|
||||
type PaginatedResponse[T any] struct {
|
||||
First int64 `json:"first"`
|
||||
Next int64 `json:"next"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Submit(ctx context.Context, sub *data.Subscription) (*data.Subscription, error)
|
||||
List(ctx context.Context, start int64, limit int) (*[]data.Subscription, error)
|
||||
UpdateByExample(ctx context.Context, example *data.Subscription) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
GetCursor(ctx context.Context, id string) (int64, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
Submit(ctx context.Context, sub *Subscription) (*Subscription, error)
|
||||
List(ctx context.Context, start int64, limit int) (*PaginatedResponse[[]Subscription], error)
|
||||
UpdateByExample(ctx context.Context, example *Subscription) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
GetCursor(ctx context.Context, id string) (int64, error)
|
||||
}
|
||||
|
||||
type RestHandler interface {
|
||||
Submit() http.HandlerFunc
|
||||
List() http.HandlerFunc
|
||||
UpdateByExample() http.HandlerFunc
|
||||
Delete() http.HandlerFunc
|
||||
GetCursor() http.HandlerFunc
|
||||
ApplyRouter() func(chi.Router)
|
||||
}
|
||||
43
server/subscription/provider.go
Normal file
43
server/subscription/provider.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"sync"
|
||||
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/repository"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/rest"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/service"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task"
|
||||
)
|
||||
|
||||
var (
|
||||
repo domain.Repository
|
||||
svc domain.Service
|
||||
hand domain.RestHandler
|
||||
|
||||
repoOnce sync.Once
|
||||
svcOnce sync.Once
|
||||
handOnce sync.Once
|
||||
)
|
||||
|
||||
func provideRepository(db *sql.DB) domain.Repository {
|
||||
repoOnce.Do(func() {
|
||||
repo = repository.New(db)
|
||||
})
|
||||
return repo
|
||||
}
|
||||
|
||||
func provideService(r domain.Repository, runner task.TaskRunner) domain.Service {
|
||||
svcOnce.Do(func() {
|
||||
svc = service.New(r, runner)
|
||||
})
|
||||
return svc
|
||||
}
|
||||
|
||||
func provideHandler(s domain.Service) domain.RestHandler {
|
||||
handOnce.Do(func() {
|
||||
hand = rest.New(s)
|
||||
})
|
||||
return hand
|
||||
}
|
||||
133
server/subscription/repository/repository.go
Normal file
133
server/subscription/repository/repository.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/data"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Delete implements domain.Repository.
|
||||
func (r *Repository) Delete(ctx context.Context, id string) error {
|
||||
conn, err := r.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.ExecContext(ctx, "DELETE FROM subscriptions WHERE id = ?", id)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCursor implements domain.Repository.
|
||||
func (r *Repository) GetCursor(ctx context.Context, id string) (int64, error) {
|
||||
conn, err := r.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
row := conn.QueryRowContext(ctx, "SELECT rowid FROM subscriptions WHERE id = ?", id)
|
||||
|
||||
var rowId int64
|
||||
|
||||
if err := row.Scan(&rowId); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
return rowId, nil
|
||||
}
|
||||
|
||||
// List implements domain.Repository.
|
||||
func (r *Repository) List(ctx context.Context, start int64, limit int) (*[]data.Subscription, error) {
|
||||
conn, err := r.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
var elements []data.Subscription
|
||||
|
||||
rows, err := conn.QueryContext(ctx, "SELECT rowid, * FROM subscriptions WHERE rowid > ? LIMIT ?", start, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var rowId int64
|
||||
var element data.Subscription
|
||||
|
||||
if err := rows.Scan(
|
||||
&rowId,
|
||||
&element.Id,
|
||||
&element.URL,
|
||||
&element.Params,
|
||||
&element.CronExpr,
|
||||
); err != nil {
|
||||
return &elements, err
|
||||
}
|
||||
|
||||
elements = append(elements, element)
|
||||
}
|
||||
|
||||
return &elements, nil
|
||||
}
|
||||
|
||||
// Submit implements domain.Repository.
|
||||
func (r *Repository) Submit(ctx context.Context, sub *data.Subscription) (*data.Subscription, error) {
|
||||
conn, err := r.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO subscriptions (id, url, params, cron) VALUES (?, ?, ?, ?)",
|
||||
uuid.NewString(),
|
||||
sub.URL,
|
||||
sub.Params,
|
||||
sub.CronExpr,
|
||||
)
|
||||
|
||||
return sub, err
|
||||
}
|
||||
|
||||
// UpdateByExample implements domain.Repository.
|
||||
func (r *Repository) UpdateByExample(ctx context.Context, example *data.Subscription) error {
|
||||
conn, err := r.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.ExecContext(
|
||||
ctx,
|
||||
"UPDATE subscriptions SET url = ?, params = ?, cron = ? WHERE id = ? OR url = ?",
|
||||
example.URL,
|
||||
example.Params,
|
||||
example.CronExpr,
|
||||
example.Id,
|
||||
example.URL,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func New(db *sql.DB) domain.Repository {
|
||||
return &Repository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
168
server/subscription/rest/handler.go
Normal file
168
server/subscription/rest/handler.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||
middlewares "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/middleware"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
|
||||
)
|
||||
|
||||
type RestHandler struct {
|
||||
svc domain.Service
|
||||
}
|
||||
|
||||
// ApplyRouter implements domain.RestHandler.
|
||||
func (h *RestHandler) ApplyRouter() func(chi.Router) {
|
||||
return func(r chi.Router) {
|
||||
if config.Instance().RequireAuth {
|
||||
r.Use(middlewares.Authenticated)
|
||||
}
|
||||
if config.Instance().UseOpenId {
|
||||
r.Use(openid.Middleware)
|
||||
}
|
||||
|
||||
r.Delete("/{id}", h.Delete())
|
||||
r.Get("/cursor", h.GetCursor())
|
||||
r.Get("/", h.List())
|
||||
r.Post("/", h.Submit())
|
||||
r.Patch("/", h.UpdateByExample())
|
||||
}
|
||||
}
|
||||
|
||||
// Delete implements domain.RestHandler.
|
||||
func (h *RestHandler) Delete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
err := h.svc.Delete(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode("ok"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetCursor implements domain.RestHandler.
|
||||
func (h *RestHandler) GetCursor() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
cursorId, err := h.svc.GetCursor(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(cursorId); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List implements domain.RestHandler.
|
||||
func (h *RestHandler) List() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var (
|
||||
startParam = r.URL.Query().Get("id")
|
||||
LimitParam = r.URL.Query().Get("limit")
|
||||
)
|
||||
|
||||
start, err := strconv.Atoi(startParam)
|
||||
if err != nil {
|
||||
start = 0
|
||||
}
|
||||
|
||||
limit, err := strconv.Atoi(LimitParam)
|
||||
if err != nil {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
res, err := h.svc.List(r.Context(), int64(start), limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit implements domain.RestHandler.
|
||||
func (h *RestHandler) Submit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var req domain.Subscription
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.Submit(r.Context(), &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateByExample implements domain.RestHandler.
|
||||
func (h *RestHandler) UpdateByExample() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var req domain.Subscription
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateByExample(r.Context(), &req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func New(svc domain.Service) domain.RestHandler {
|
||||
return &RestHandler{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
141
server/subscription/service/service.go
Normal file
141
server/subscription/service/service.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/data"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
r domain.Repository
|
||||
runner task.TaskRunner
|
||||
}
|
||||
|
||||
func New(r domain.Repository, runner task.TaskRunner) domain.Service {
|
||||
s := &Service{
|
||||
r: r,
|
||||
runner: runner,
|
||||
}
|
||||
|
||||
// very crude recoverer
|
||||
initial, _ := s.List(context.Background(), 0, math.MaxInt)
|
||||
if initial != nil {
|
||||
for _, v := range initial.Data {
|
||||
s.runner.Submit(&v)
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func fromDB(model *data.Subscription) domain.Subscription {
|
||||
return domain.Subscription{
|
||||
Id: model.Id,
|
||||
URL: model.URL,
|
||||
Params: model.Params,
|
||||
CronExpr: model.CronExpr,
|
||||
}
|
||||
}
|
||||
|
||||
func toDB(dto *domain.Subscription) data.Subscription {
|
||||
return data.Subscription{
|
||||
Id: dto.Id,
|
||||
URL: dto.URL,
|
||||
Params: dto.Params,
|
||||
CronExpr: dto.CronExpr,
|
||||
}
|
||||
}
|
||||
|
||||
// Delete implements domain.Service.
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
s.runner.StopTask(id)
|
||||
return s.r.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// GetCursor implements domain.Service.
|
||||
func (s *Service) GetCursor(ctx context.Context, id string) (int64, error) {
|
||||
return s.r.GetCursor(ctx, id)
|
||||
}
|
||||
|
||||
// List implements domain.Service.
|
||||
func (s *Service) List(ctx context.Context, start int64, limit int) (
|
||||
*domain.PaginatedResponse[[]domain.Subscription],
|
||||
error,
|
||||
) {
|
||||
dbSubs, err := s.r.List(ctx, start, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subs := make([]domain.Subscription, len(*dbSubs))
|
||||
|
||||
for i, v := range *dbSubs {
|
||||
subs[i] = fromDB(&v)
|
||||
}
|
||||
|
||||
var (
|
||||
first int64
|
||||
next int64
|
||||
)
|
||||
|
||||
if len(subs) > 0 {
|
||||
first, err = s.r.GetCursor(ctx, subs[0].Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err = s.r.GetCursor(ctx, subs[len(subs)-1].Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &domain.PaginatedResponse[[]domain.Subscription]{
|
||||
First: first,
|
||||
Next: next,
|
||||
Data: subs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Submit implements domain.Service.
|
||||
func (s *Service) Submit(ctx context.Context, sub *domain.Subscription) (*domain.Subscription, error) {
|
||||
if sub.CronExpr == "" {
|
||||
sub.CronExpr = "*/5 * * * *"
|
||||
}
|
||||
|
||||
_, err := cron.ParseStandard(sub.CronExpr)
|
||||
if err != nil {
|
||||
return nil, errors.Join(errors.New("failed parsing cron expression"), err)
|
||||
}
|
||||
|
||||
subDB, err := s.r.Submit(ctx, &data.Subscription{
|
||||
URL: sub.URL,
|
||||
Params: sub.Params,
|
||||
CronExpr: sub.CronExpr,
|
||||
})
|
||||
|
||||
retval := fromDB(subDB)
|
||||
|
||||
if err := s.runner.Submit(sub); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &retval, err
|
||||
}
|
||||
|
||||
// UpdateByExample implements domain.Service.
|
||||
func (s *Service) UpdateByExample(ctx context.Context, example *domain.Subscription) error {
|
||||
_, err := cron.ParseStandard(example.CronExpr)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("failed parsing cron expression"), err)
|
||||
}
|
||||
|
||||
e := toDB(example)
|
||||
|
||||
return s.r.UpdateByExample(ctx, &e)
|
||||
}
|
||||
1
server/subscription/subscription.go
Normal file
1
server/subscription/subscription.go
Normal file
@@ -0,0 +1 @@
|
||||
package subscription
|
||||
177
server/subscription/task/runner.go
Normal file
177
server/subscription/task/runner.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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/internal"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
type TaskRunner interface {
|
||||
Submit(subcription *domain.Subscription) error
|
||||
Spawner(ctx context.Context)
|
||||
StopTask(id string) error
|
||||
Recoverer()
|
||||
}
|
||||
|
||||
type monitorTask struct {
|
||||
Done chan struct{}
|
||||
Schedule cron.Schedule
|
||||
Subscription *domain.Subscription
|
||||
}
|
||||
|
||||
type CronTaskRunner struct {
|
||||
mq *internal.MessageQueue
|
||||
db *internal.MemoryDB
|
||||
|
||||
tasks chan monitorTask
|
||||
errors chan error
|
||||
|
||||
running map[string]*monitorTask
|
||||
}
|
||||
|
||||
func NewCronTaskRunner(mq *internal.MessageQueue, db *internal.MemoryDB) TaskRunner {
|
||||
return &CronTaskRunner{
|
||||
mq: mq,
|
||||
db: db,
|
||||
tasks: make(chan monitorTask),
|
||||
errors: make(chan error),
|
||||
running: make(map[string]*monitorTask),
|
||||
}
|
||||
}
|
||||
|
||||
var argsSplitterRe = regexp.MustCompile(`(?mi)[^\s"']+|"([^"]*)"|'([^']*)'`)
|
||||
|
||||
func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error {
|
||||
schedule, err := cron.ParseStandard(subcription.CronExpr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
job := monitorTask{
|
||||
Done: make(chan struct{}),
|
||||
Schedule: schedule,
|
||||
Subscription: subcription,
|
||||
}
|
||||
|
||||
t.tasks <- job
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handles the entire lifecylce of a monitor job.
|
||||
func (t *CronTaskRunner) Spawner(ctx context.Context) {
|
||||
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() {
|
||||
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")
|
||||
}
|
||||
75
server/twitch/auth.go
Normal file
75
server/twitch/auth.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const authURL = "https://id.twitch.tv/oauth2/token"
|
||||
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
type AccessToken struct {
|
||||
Token string
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
type AuthenticationManager struct {
|
||||
clientId string
|
||||
clientSecret string
|
||||
accesToken *AccessToken
|
||||
}
|
||||
|
||||
func NewAuthenticationManager(clientId, clientSecret string) *AuthenticationManager {
|
||||
return &AuthenticationManager{
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
accesToken: &AccessToken{},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthenticationManager) GetAccessToken() (*AccessToken, error) {
|
||||
if a.accesToken != nil && a.accesToken.Token != "" && a.accesToken.Expiry.After(time.Now()) {
|
||||
return a.accesToken, nil
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("client_id", a.clientId)
|
||||
data.Set("client_secret", a.clientSecret)
|
||||
data.Set("grant_type", "client_credentials")
|
||||
|
||||
resp, err := http.PostForm(authURL, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("errore richiesta token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("status non OK: %s", resp.Status)
|
||||
}
|
||||
|
||||
var auth AuthResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil {
|
||||
return nil, fmt.Errorf("errore decoding JSON: %w", err)
|
||||
}
|
||||
|
||||
token := &AccessToken{
|
||||
Token: auth.AccessToken,
|
||||
Expiry: time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second),
|
||||
}
|
||||
|
||||
a.accesToken = token
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (a *AuthenticationManager) GetClientId() string {
|
||||
return a.clientId
|
||||
}
|
||||
91
server/twitch/client.go
Normal file
91
server/twitch/client.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const twitchAPIURL = "https://api.twitch.tv/helix"
|
||||
|
||||
type Client struct {
|
||||
authenticationManager AuthenticationManager
|
||||
}
|
||||
|
||||
func NewTwitchClient(am *AuthenticationManager) *Client {
|
||||
return &Client{
|
||||
authenticationManager: *am,
|
||||
}
|
||||
}
|
||||
|
||||
type streamResp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
UserName string `json:"user_name"`
|
||||
Title string `json:"title"`
|
||||
GameName string `json:"game_name"`
|
||||
StartedAt string `json:"started_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(endpoint string, params map[string]string) ([]byte, error) {
|
||||
token, err := c.authenticationManager.GetAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqURL := twitchAPIURL + endpoint
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
for k, v := range params {
|
||||
q.Set(k, v)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Client-Id", c.authenticationManager.GetClientId())
|
||||
req.Header.Set("Authorization", "Bearer "+token.Token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (c *Client) PollStream(channel string, liveChannel chan<- *StreamInfo) error {
|
||||
body, err := c.doRequest("/streams", map[string]string{"user_login": channel})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sr streamResp
|
||||
if err := json.Unmarshal(body, &sr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(sr.Data) == 0 {
|
||||
liveChannel <- &StreamInfo{UserName: channel, IsLive: false}
|
||||
return nil
|
||||
}
|
||||
|
||||
s := sr.Data[0]
|
||||
started, _ := time.Parse(time.RFC3339, s.StartedAt)
|
||||
|
||||
liveChannel <- &StreamInfo{
|
||||
ID: s.ID,
|
||||
UserName: s.UserName,
|
||||
Title: s.Title,
|
||||
GameName: s.GameName,
|
||||
StartedAt: started,
|
||||
IsLive: true,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
149
server/twitch/monitor.go
Normal file
149
server/twitch/monitor.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"iter"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||
)
|
||||
|
||||
type Monitor struct {
|
||||
liveChannel chan *StreamInfo
|
||||
monitored map[string]*Client
|
||||
lastState map[string]bool
|
||||
mu sync.RWMutex
|
||||
authenticationManager *AuthenticationManager
|
||||
}
|
||||
|
||||
func NewMonitor(authenticationManager *AuthenticationManager) *Monitor {
|
||||
return &Monitor{
|
||||
liveChannel: make(chan *StreamInfo, 16),
|
||||
monitored: make(map[string]*Client),
|
||||
lastState: make(map[string]bool),
|
||||
authenticationManager: authenticationManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) Add(user string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.monitored[user] = NewTwitchClient(m.authenticationManager)
|
||||
slog.Info("added user to twitch monitor", slog.String("user", user))
|
||||
}
|
||||
|
||||
func (m *Monitor) Monitor(ctx context.Context, interval time.Duration, handler func(url string) error) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.mu.RLock()
|
||||
for user, client := range m.monitored {
|
||||
u := user
|
||||
c := client
|
||||
|
||||
go func() {
|
||||
if err := c.PollStream(u, m.liveChannel); err != nil {
|
||||
slog.Error("polling failed", slog.String("user", u), slog.Any("err", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
case stream := <-m.liveChannel:
|
||||
wasLive := m.lastState[stream.UserName]
|
||||
if stream.IsLive && !wasLive {
|
||||
slog.Info("stream went live", slog.String("user", stream.UserName))
|
||||
if err := handler(fmt.Sprintf("https://www.twitch.tv/%s", stream.UserName)); err != nil {
|
||||
slog.Error("handler failed", slog.String("user", stream.UserName), slog.Any("err", err))
|
||||
}
|
||||
}
|
||||
m.lastState[stream.UserName] = stream.IsLive
|
||||
|
||||
case <-ctx.Done():
|
||||
slog.Info("stopping twitch monitor")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) GetMonitoredUsers() iter.Seq[string] {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return maps.Keys(m.monitored)
|
||||
}
|
||||
|
||||
func (m *Monitor) DeleteUser(user string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.monitored, user)
|
||||
delete(m.lastState, user)
|
||||
}
|
||||
|
||||
func DEFAULT_DOWNLOAD_HANDLER(db *internal.MemoryDB, mq *internal.MessageQueue) func(url string) error {
|
||||
return func(url string) error {
|
||||
p := &internal.Process{
|
||||
Url: url,
|
||||
Livestream: true,
|
||||
Params: []string{"--downloader", "ffmpeg", "--no-part"},
|
||||
}
|
||||
db.Set(p)
|
||||
mq.Publish(p)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) Persist() error {
|
||||
filename := filepath.Join(config.Instance().SessionFilePath, "twitch-monitor.dat")
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := gob.NewEncoder(f)
|
||||
users := make([]string, 0, len(m.monitored))
|
||||
|
||||
for user := range m.monitored {
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return enc.Encode(users)
|
||||
}
|
||||
|
||||
func (m *Monitor) Restore() error {
|
||||
filename := filepath.Join(config.Instance().SessionFilePath, "twitch-monitor.dat")
|
||||
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
dec := gob.NewDecoder(f)
|
||||
var users []string
|
||||
if err := dec.Decode(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.monitored = make(map[string]*Client)
|
||||
for _, user := range users {
|
||||
m.monitored[user] = NewTwitchClient(m.authenticationManager)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
65
server/twitch/rest.go
Normal file
65
server/twitch/rest.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type addUserReq struct {
|
||||
User string `json:"user"`
|
||||
}
|
||||
|
||||
func MonitorUserHandler(m *Monitor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req addUserReq
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
m.Add(req.User)
|
||||
|
||||
if err := json.NewEncoder(w).Encode("ok"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetMonitoredUsers(m *Monitor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
it := m.GetMonitoredUsers()
|
||||
|
||||
users := slices.Collect(it)
|
||||
if users == nil {
|
||||
users = make([]string, 0)
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(users); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteUser(m *Monitor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user := chi.URLParam(r, "user")
|
||||
|
||||
if user == "" {
|
||||
http.Error(w, "empty user", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
m.DeleteUser(user)
|
||||
|
||||
if err := json.NewEncoder(w).Encode("ok"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
20
server/twitch/types.go
Normal file
20
server/twitch/types.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package twitch
|
||||
|
||||
import "time"
|
||||
|
||||
type StreamInfo struct {
|
||||
ID string
|
||||
UserName string
|
||||
Title string
|
||||
GameName string
|
||||
StartedAt time.Time
|
||||
IsLive bool
|
||||
}
|
||||
|
||||
type VodInfo struct {
|
||||
ID string
|
||||
Title string
|
||||
URL string
|
||||
Duration string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
Reference in New Issue
Block a user