Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65960fb560 | ||
|
|
1141903512 | ||
| ff93bd552f | |||
|
|
016d8557e6 | ||
| 5e9f92a06f |
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
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
result/
|
result/
|
||||||
result
|
result
|
||||||
dist
|
dist
|
||||||
|
.pnpm-store/
|
||||||
.pnpm-debug.log
|
.pnpm-debug.log
|
||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
@@ -26,3 +27,5 @@ frontend/.pnp.loader.mjs
|
|||||||
frontend/.yarn/install-state.gz
|
frontend/.yarn/install-state.gz
|
||||||
.db.lock
|
.db.lock
|
||||||
livestreams.dat
|
livestreams.dat
|
||||||
|
.vite/deps
|
||||||
|
archive.txt
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "yt-dlp-webui",
|
"name": "yt-dlp-webui",
|
||||||
"version": "3.2.3",
|
"version": "3.2.5",
|
||||||
"description": "Frontend compontent of yt-dlp-webui",
|
"description": "Frontend compontent of yt-dlp-webui",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
|
"type": "module",
|
||||||
"author": "marcopiovanello",
|
"author": "marcopiovanello",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import LiveTvIcon from '@mui/icons-material/LiveTv'
|
|||||||
import Menu from '@mui/icons-material/Menu'
|
import Menu from '@mui/icons-material/Menu'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||||
|
import UpdateIcon from '@mui/icons-material/Update'
|
||||||
import { Box, createTheme } from '@mui/material'
|
import { Box, createTheme } from '@mui/material'
|
||||||
import CssBaseline from '@mui/material/CssBaseline'
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
@@ -140,6 +141,19 @@ export default function Layout() {
|
|||||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to={'/subscriptions'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<UpdateIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={i18n.t('subscriptionsButtonLabel')} />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
<Link to={'/monitor'} style={
|
<Link to={'/monitor'} style={
|
||||||
{
|
{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ keys:
|
|||||||
overridesAnchor: Anul·lacions
|
overridesAnchor: Anul·lacions
|
||||||
pathOverrideOption: Sobreescriure en la ruta de sortida
|
pathOverrideOption: Sobreescriure en la ruta de sortida
|
||||||
filenameOverrideOption: Sobreescriure el nom del fitxer
|
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)
|
customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat)
|
||||||
customPath: Ruta personalitzada
|
customPath: Ruta personalitzada
|
||||||
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
|
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ keys:
|
|||||||
overridesAnchor: Überschreibungen
|
overridesAnchor: Überschreibungen
|
||||||
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
|
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
|
||||||
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
|
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
|
||||||
|
autoFileExtensionOption: Dateierweiterung automatisch hinzufügen
|
||||||
customFilename: Benutzerdefinierter Dateiname (Leer lassen um Standardwert zu nutzen)
|
customFilename: Benutzerdefinierter Dateiname (Leer lassen um Standardwert zu nutzen)
|
||||||
customPath: Benutzerdefinierter Pfad
|
customPath: Benutzerdefinierter Pfad
|
||||||
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (Auf viel Macht folgt große Verantwortung)
|
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (Auf viel Macht folgt große Verantwortung)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ keys:
|
|||||||
overridesAnchor: Overrides
|
overridesAnchor: Overrides
|
||||||
pathOverrideOption: Enable output path overriding
|
pathOverrideOption: Enable output path overriding
|
||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Enable output file name overriding
|
||||||
|
autoFileExtensionOption: Automatically add file extension
|
||||||
customFilename: Custom filename (leave blank to use default)
|
customFilename: Custom filename (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
|
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
|
||||||
@@ -70,3 +71,12 @@ keys:
|
|||||||
noFilesFound: 'No Files Found'
|
noFilesFound: 'No Files Found'
|
||||||
tableView: 'Table View'
|
tableView: 'Table View'
|
||||||
deleteSelected: 'Delete selected'
|
deleteSelected: 'Delete selected'
|
||||||
|
subscriptionsButtonLabel: 'Subscriptions'
|
||||||
|
subscriptionsEmptyLabel: 'No subscriptions'
|
||||||
|
subscriptionsURLInput: 'Channel URL'
|
||||||
|
subscriptionsInfo: |
|
||||||
|
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||||
|
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||||
|
cronExpressionLabel: 'Cron expression'
|
||||||
|
editButtonLabel: 'Edit'
|
||||||
|
newSubscriptionButton: New subscription
|
||||||
@@ -21,6 +21,7 @@ keys:
|
|||||||
overridesAnchor: Anulaciones
|
overridesAnchor: Anulaciones
|
||||||
pathOverrideOption: Sobreescribir en la ruta de salida
|
pathOverrideOption: Sobreescribir en la ruta de salida
|
||||||
filenameOverrideOption: Sobreescribir el nombre del fichero
|
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)
|
customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
|
||||||
customPath: Ruta personalizada
|
customPath: Ruta personalizada
|
||||||
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ keys:
|
|||||||
overridesAnchor: Remplacer
|
overridesAnchor: Remplacer
|
||||||
pathOverrideOption: Activer le remplacement du chemin de sortie
|
pathOverrideOption: Activer le remplacement du chemin de sortie
|
||||||
filenameOverrideOption: Activer le remplacement du nom du fichier 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)
|
customFilename: Nom de fichier personnalisé (laisser vide pour utiliser le nom par défaut)
|
||||||
customPath: Chemin personnalisé
|
customPath: Chemin personnalisé
|
||||||
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
|
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ keys:
|
|||||||
overridesAnchor: Felülbírálások
|
overridesAnchor: Felülbírálások
|
||||||
pathOverrideOption: Letöltési útvonal felülbírálása
|
pathOverrideOption: Letöltési útvonal felülbírálása
|
||||||
filenameOverrideOption: Letöltési fájlnév 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)
|
customFilename: Egyedi fájlnév (hagyd üresen, hogy a fájlnév automatikusan generálódjon)
|
||||||
customPath: Egyedi útvonal
|
customPath: Egyedi útvonal
|
||||||
customArgs: Egyedi yt-dlp argumentumok (Nagy hatalommal nagy felelősség jár.)
|
customArgs: Egyedi yt-dlp argumentumok (Nagy hatalommal nagy felelősség jár.)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ keys:
|
|||||||
overridesAnchor: Sovrascritture
|
overridesAnchor: Sovrascritture
|
||||||
pathOverrideOption: Abilita sovrascrittura percorso di output
|
pathOverrideOption: Abilita sovrascrittura percorso di output
|
||||||
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
|
||||||
|
autoFileExtensionOption: Aggiungi estensione automaticamente
|
||||||
customFilename: Custom filename (leave blank to use default)
|
customFilename: Custom filename (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ keys:
|
|||||||
overridesAnchor: 上書き
|
overridesAnchor: 上書き
|
||||||
pathOverrideOption: 保存するディレクトリ
|
pathOverrideOption: 保存するディレクトリ
|
||||||
filenameOverrideOption: ファイル名の上書き
|
filenameOverrideOption: ファイル名の上書き
|
||||||
|
autoFileExtensionOption: 自動ファイル拡張子
|
||||||
customFilename: (空白の場合は元のファイル名)
|
customFilename: (空白の場合は元のファイル名)
|
||||||
customPath: 保存先
|
customPath: 保存先
|
||||||
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ keys:
|
|||||||
overridesAnchor: Overrides
|
overridesAnchor: Overrides
|
||||||
pathOverrideOption: Enable output path overriding
|
pathOverrideOption: Enable output path overriding
|
||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Enable output file name overriding
|
||||||
|
autoFileExtensionOption: 자동으로 파일 확장자 추가
|
||||||
customFilename: Custom filename (leave blank to use default)
|
customFilename: Custom filename (leave blank to use default)
|
||||||
customPath: Custom path
|
customPath: Custom path
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ keys:
|
|||||||
overridesAnchor: Przedefiniuj
|
overridesAnchor: Przedefiniuj
|
||||||
pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej
|
pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej
|
||||||
filenameOverrideOption: Aktywuj zastępowanie nazwy pliku źródłowego
|
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)
|
customFilename: Wprowadź nazwę pliku (pozostaw puste, aby użyć nazwy domyślnej)
|
||||||
customPath: Ustaw ścieżkę
|
customPath: Ustaw ścieżkę
|
||||||
customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność)
|
customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ keys:
|
|||||||
overridesAnchor: Substituições
|
overridesAnchor: Substituições
|
||||||
pathOverrideOption: Habilitar substituição do caminho de saída
|
pathOverrideOption: Habilitar substituição do caminho de saída
|
||||||
filenameOverrideOption: Habilitar substituição do nome do arquivo 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)
|
customFilename: Nome de arquivo personalizado (deixe em branco para usar o padrão)
|
||||||
customPath: Caminho personalizado
|
customPath: Caminho personalizado
|
||||||
customArgs: Habilitar argumentos personalizados do yt-dlp (grandes poderes = grandes responsabilidades)
|
customArgs: Habilitar argumentos personalizados do yt-dlp (grandes poderes = grandes responsabilidades)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ keys:
|
|||||||
overridesAnchor: Переопределить
|
overridesAnchor: Переопределить
|
||||||
pathOverrideOption: Активировать переопределение выходного пути
|
pathOverrideOption: Активировать переопределение выходного пути
|
||||||
filenameOverrideOption: Активировать переопределение имени выходного файла
|
filenameOverrideOption: Активировать переопределение имени выходного файла
|
||||||
|
autoFileExtensionOption: Автоматическое расширение файла
|
||||||
customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию)
|
customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию)
|
||||||
customPath: Задать путь
|
customPath: Задать путь
|
||||||
customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность)
|
customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ keys:
|
|||||||
overridesAnchor: Överskrivningar
|
overridesAnchor: Överskrivningar
|
||||||
pathOverrideOption: Tillåt överskrivning av filsökvägen
|
pathOverrideOption: Tillåt överskrivning av filsökvägen
|
||||||
filenameOverrideOption: Tillåt överskrivning av filnamn
|
filenameOverrideOption: Tillåt överskrivning av filnamn
|
||||||
|
autoFileExtensionOption: Lägg till filändelse automatiskt
|
||||||
customFilename: Eget filnamn (lämna blankt för standardnamn)
|
customFilename: Eget filnamn (lämna blankt för standardnamn)
|
||||||
customPath: Egen filsökväg
|
customPath: Egen filsökväg
|
||||||
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
|
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ keys:
|
|||||||
overridesAnchor: Перевизначити
|
overridesAnchor: Перевизначити
|
||||||
pathOverrideOption: Активувати перевизначення вихідного шляху
|
pathOverrideOption: Активувати перевизначення вихідного шляху
|
||||||
filenameOverrideOption: Активувати перевизначення імені вихідного файлу
|
filenameOverrideOption: Активувати перевизначення імені вихідного файлу
|
||||||
|
autoFileExtensionOption: Автоматичне додавання розширення файлу
|
||||||
customFilename: Введіть ім'я файлу (залишіть порожнім, щоб використовувати значення за замовчуванням)
|
customFilename: Введіть ім'я файлу (залишіть порожнім, щоб використовувати значення за замовчуванням)
|
||||||
customPath: Задати шлях
|
customPath: Задати шлях
|
||||||
customArgs: Включити аргументи, що настроюються yt-dlp (велика сила = велика відповідальність)
|
customArgs: Включити аргументи, що настроюються yt-dlp (велика сила = велика відповідальність)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ keys:
|
|||||||
overridesAnchor: 覆盖
|
overridesAnchor: 覆盖
|
||||||
pathOverrideOption: 启用输出路径覆盖
|
pathOverrideOption: 启用输出路径覆盖
|
||||||
filenameOverrideOption: 启用输出文件名覆盖
|
filenameOverrideOption: 启用输出文件名覆盖
|
||||||
|
autoFileExtensionOption: 自动文件扩展名
|
||||||
customFilename: 自定义文件名(留空使用默认值)
|
customFilename: 自定义文件名(留空使用默认值)
|
||||||
customPath: 自定义路径
|
customPath: 自定义路径
|
||||||
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
|
customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { getOrElse } from 'fp-ts/lib/Either'
|
import { getOrElse } from 'fp-ts/lib/Either'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { atom } from 'jotai'
|
||||||
|
import { atomWithStorage } from 'jotai/utils'
|
||||||
import { ffetch } from '../lib/httpClient'
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { CustomTemplate } from '../types'
|
import { CustomTemplate } from '../types'
|
||||||
import { serverSideCookiesState, serverURL } from './settings'
|
import { serverSideCookiesState, serverURL } from './settings'
|
||||||
import { atom } from 'jotai'
|
|
||||||
import { atomWithStorage } from 'jotai/utils'
|
|
||||||
|
|
||||||
export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
|
export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
|
||||||
await get(serverSideCookiesState)
|
await get(serverSideCookiesState)
|
||||||
@@ -22,12 +22,6 @@ export const filenameTemplateState = atomWithStorage(
|
|||||||
localStorage.getItem('lastFilenameTemplate') ?? ''
|
localStorage.getItem('lastFilenameTemplate') ?? ''
|
||||||
)
|
)
|
||||||
|
|
||||||
export const downloadTemplateState = atom<Promise<string>>(async (get) =>
|
|
||||||
`${get(customArgsState)} ${await get(cookiesTemplateState)}`
|
|
||||||
.replace(/ +/g, ' ')
|
|
||||||
.trim()
|
|
||||||
)
|
|
||||||
|
|
||||||
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
|
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
|
||||||
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
||||||
const either = await task()
|
const either = await task()
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface SettingsState {
|
|||||||
cliArgs: string
|
cliArgs: string
|
||||||
formatSelection: boolean
|
formatSelection: boolean
|
||||||
fileRenaming: boolean
|
fileRenaming: boolean
|
||||||
|
autoFileExtension: boolean
|
||||||
pathOverriding: boolean
|
pathOverriding: boolean
|
||||||
enableCustomArgs: boolean
|
enableCustomArgs: boolean
|
||||||
listView: boolean
|
listView: boolean
|
||||||
@@ -82,6 +83,11 @@ export const fileRenamingState = atomWithStorage(
|
|||||||
localStorage.getItem('file-renaming') === 'true'
|
localStorage.getItem('file-renaming') === 'true'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const autoFileExtensionState = atomWithStorage(
|
||||||
|
'auto-file-extension',
|
||||||
|
localStorage.getItem('auto-file-extension') === 'true'
|
||||||
|
)
|
||||||
|
|
||||||
export const pathOverridingState = atomWithStorage(
|
export const pathOverridingState = atomWithStorage(
|
||||||
'path-overriding',
|
'path-overriding',
|
||||||
localStorage.getItem('path-overriding') === 'true'
|
localStorage.getItem('path-overriding') === 'true'
|
||||||
@@ -168,6 +174,7 @@ export const settingsState = atom<SettingsState>((get) => ({
|
|||||||
cliArgs: get(latestCliArgumentsState),
|
cliArgs: get(latestCliArgumentsState),
|
||||||
formatSelection: get(formatSelectionState),
|
formatSelection: get(formatSelectionState),
|
||||||
fileRenaming: get(fileRenamingState),
|
fileRenaming: get(fileRenamingState),
|
||||||
|
autoFileExtension: get(autoFileExtensionState),
|
||||||
pathOverriding: get(pathOverridingState),
|
pathOverriding: get(pathOverridingState),
|
||||||
enableCustomArgs: get(enableCustomArgsState),
|
enableCustomArgs: get(enableCustomArgsState),
|
||||||
listView: get(listViewState),
|
listView: get(listViewState),
|
||||||
|
|||||||
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,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
|
Select,
|
||||||
|
SelectChangeEvent,
|
||||||
TextField
|
TextField
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import AppBar from '@mui/material/AppBar'
|
import AppBar from '@mui/material/AppBar'
|
||||||
@@ -25,14 +28,13 @@ import {
|
|||||||
FC,
|
FC,
|
||||||
Suspense,
|
Suspense,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
|
cookiesTemplateState,
|
||||||
customArgsState,
|
customArgsState,
|
||||||
downloadTemplateState,
|
|
||||||
filenameTemplateState,
|
filenameTemplateState,
|
||||||
savedTemplatesState
|
savedTemplatesState
|
||||||
} from '../atoms/downloadTemplate'
|
} from '../atoms/downloadTemplate'
|
||||||
@@ -44,6 +46,7 @@ import { useI18n } from '../hooks/useI18n'
|
|||||||
import { useRPC } from '../hooks/useRPC'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import type { DLMetadata } from '../types'
|
import type { DLMetadata } from '../types'
|
||||||
import { toFormatArgs } from '../utils'
|
import { toFormatArgs } from '../utils'
|
||||||
|
import CustomArgsTextField from './CustomArgsTextField'
|
||||||
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
||||||
import LoadingBackdrop from './LoadingBackdrop'
|
import LoadingBackdrop from './LoadingBackdrop'
|
||||||
|
|
||||||
@@ -66,8 +69,9 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
const settings = useAtomValue(settingsState)
|
const settings = useAtomValue(settingsState)
|
||||||
const isConnected = useAtomValue(connectedState)
|
const isConnected = useAtomValue(connectedState)
|
||||||
const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
|
const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
|
||||||
const downloadTemplate = useAtomValue(downloadTemplateState)
|
|
||||||
const savedTemplates = useAtomValue(savedTemplatesState)
|
const savedTemplates = useAtomValue(savedTemplatesState)
|
||||||
|
const customArgs = useAtomValue(customArgsState)
|
||||||
|
const cookies = useAtomValue(cookiesTemplateState)
|
||||||
|
|
||||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||||
@@ -75,14 +79,14 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||||
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
|
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
|
||||||
|
|
||||||
const [customArgs, setCustomArgs] = useAtom(customArgsState)
|
|
||||||
|
|
||||||
const [downloadPath, setDownloadPath] = useState('')
|
const [downloadPath, setDownloadPath] = useState('')
|
||||||
|
|
||||||
const [filenameTemplate, setFilenameTemplate] = useAtom(
|
const [filenameTemplate, setFilenameTemplate] = useAtom(
|
||||||
filenameTemplateState
|
filenameTemplateState
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [fileExtension, setFileExtension] = useState('.%(ext)s')
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
|
|
||||||
const [isPlaylist, setIsPlaylist] = useState(false)
|
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||||
@@ -96,10 +100,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCustomArgs('')
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||||
*/
|
*/
|
||||||
@@ -110,12 +110,16 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
||||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
||||||
|
|
||||||
|
const downloadTemplate = `${customArgsState} ${cookies}`
|
||||||
|
.replace(/ +/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 10))
|
await new Promise(r => setTimeout(r, 10))
|
||||||
client.download({
|
client.download({
|
||||||
url: immediate || line,
|
url: immediate || line,
|
||||||
args: `${toFormatArgs(codes)} ${downloadTemplate}`,
|
args: `${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||||
pathOverride: downloadPath ?? '',
|
pathOverride: downloadPath ?? '',
|
||||||
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
renameTo: settings.fileRenaming ? filenameTemplate + (settings.autoFileExtension ? fileExtension : '') : '',
|
||||||
playlist: isPlaylist,
|
playlist: isPlaylist,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -169,8 +173,8 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
setFilenameTemplate(e.target.value)
|
setFilenameTemplate(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileExtensionChange = (e: SelectChangeEvent<string>) => {
|
||||||
setCustomArgs(e.target.value)
|
setFileExtension(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -268,25 +272,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||||
{
|
{settings.enableCustomArgs &&
|
||||||
settings.enableCustomArgs &&
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<CustomArgsTextField />
|
||||||
fullWidth
|
|
||||||
label={i18n.t('customArgsInput')}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={handleCustomArgsChange}
|
|
||||||
value={customArgs}
|
|
||||||
disabled={
|
|
||||||
!isConnected ||
|
|
||||||
(settings.formatSelection && downloadFormats != null)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
settings.fileRenaming &&
|
settings.fileRenaming &&
|
||||||
<Grid item xs={settings.pathOverriding ? 8 : 12}>
|
<Grid item xs={
|
||||||
|
!settings.autoFileExtension && !settings.pathOverriding
|
||||||
|
? 12
|
||||||
|
: !settings.autoFileExtension && settings.pathOverriding
|
||||||
|
? 8
|
||||||
|
: settings.autoFileExtension && !settings.pathOverriding
|
||||||
|
? 10
|
||||||
|
: 6
|
||||||
|
}>
|
||||||
<TextField
|
<TextField
|
||||||
sx={{ mt: 1 }}
|
sx={{ mt: 1 }}
|
||||||
ref={customFilenameInputRef}
|
ref={customFilenameInputRef}
|
||||||
@@ -302,6 +303,22 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
settings.autoFileExtension &&
|
||||||
|
<Grid item xs={2}>
|
||||||
|
<Select
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('autoFileExtension')}
|
||||||
|
value={fileExtension}
|
||||||
|
onChange={handleFileExtensionChange}
|
||||||
|
variant="outlined">
|
||||||
|
<MenuItem value=".%(ext)s">Auto</MenuItem>
|
||||||
|
<MenuItem value=".mp4">mp4</MenuItem>
|
||||||
|
<MenuItem value=".mkv">mkv</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
{
|
{
|
||||||
settings.pathOverriding &&
|
settings.pathOverriding &&
|
||||||
<Grid item xs={4}>
|
<Grid item xs={4}>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import CloseIcon from '@mui/icons-material/Close'
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AppBar,
|
AppBar,
|
||||||
@@ -20,13 +18,13 @@ import {
|
|||||||
import { TransitionProps } from '@mui/material/transitions'
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
import { matchW } from 'fp-ts/lib/Either'
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
||||||
import { serverURL } from '../atoms/settings'
|
import { serverURL } from '../atoms/settings'
|
||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { ffetch } from '../lib/httpClient'
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { CustomTemplate } from '../types'
|
import { CustomTemplate } from '../types'
|
||||||
import { useAtomValue } from 'jotai'
|
|
||||||
import TemplateTextField from './TemplateTextField'
|
import TemplateTextField from './TemplateTextField'
|
||||||
|
|
||||||
const Transition = forwardRef(function Transition(
|
const Transition = forwardRef(function Transition(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const VersionIndicator: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<Chip label={`UI v3.2.5`} variant="outlined" size="small" />
|
||||||
<Chip label={`RPC v${version.rpcVersion}`} variant="outlined" size="small" />
|
<Chip label={`RPC v${version.rpcVersion}`} variant="outlined" size="small" />
|
||||||
<Chip label={`yt-dlp v${version.ytdlpVersion}`} variant="outlined" size="small" />
|
<Chip label={`yt-dlp v${version.ytdlpVersion}`} variant="outlined" size="small" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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
|
||||||
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,6 @@
|
|||||||
import { tryCatch } from 'fp-ts/TaskEither'
|
import { tryCatch } from 'fp-ts/TaskEither'
|
||||||
|
|
||||||
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
async function fetcher<T>(url: string, opt?: RequestInit): Promise<T> {
|
||||||
() => fetcher<T>(url, opt),
|
|
||||||
(e) => `error while fetching: ${e}`
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
|
||||||
const jwt = localStorage.getItem('token')
|
const jwt = localStorage.getItem('token')
|
||||||
|
|
||||||
if (opt && !opt.headers) {
|
if (opt && !opt.headers) {
|
||||||
@@ -26,5 +20,11 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw await res.text()
|
throw await res.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as T
|
return res.json() as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
||||||
|
() => fetcher<T>(url, opt),
|
||||||
|
(e) => `error while fetching: ${e}`
|
||||||
|
)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default class Translator {
|
|||||||
t(key: string): string {
|
t(key: string): string {
|
||||||
if (this.current) {
|
if (this.current) {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
return this.current[key] ?? fallback.keys[key]
|
return this.current[key] ?? fallback.keys[key] ?? 'caption not defined'
|
||||||
}
|
}
|
||||||
return 'caption not defined'
|
return 'caption not defined'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ export class RPCClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private argsSanitizer(args: string): string[] {
|
private argsSanitizer(args: string): string[] {
|
||||||
|
const splitOnlyWhitespaces = /[^\s"']+|"([^"]*)"|'([^']*)'/gm
|
||||||
|
|
||||||
return args
|
return args
|
||||||
.split(' ')
|
.match(splitOnlyWhitespaces)
|
||||||
.map(a => a.trim().replaceAll('"', ''))
|
?.map(a => a.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendHTTP<T>(req: RPCRequest) {
|
private async sendHTTP<T>(req: RPCRequest) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const Archive = lazy(() => import('./views/Archive'))
|
|||||||
const Settings = lazy(() => import('./views/Settings'))
|
const Settings = lazy(() => import('./views/Settings'))
|
||||||
const LiveStream = lazy(() => import('./views/Livestream'))
|
const LiveStream = lazy(() => import('./views/Livestream'))
|
||||||
const Filebrowser = lazy(() => import('./views/Filebrowser'))
|
const Filebrowser = lazy(() => import('./views/Filebrowser'))
|
||||||
|
const Subscriptions = lazy(() => import('./views/Subscriptions'))
|
||||||
|
|
||||||
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
||||||
|
|
||||||
@@ -73,6 +74,19 @@ export const router = createHashRouter([
|
|||||||
</Suspense >
|
</Suspense >
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/subscriptions',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Subscriptions />
|
||||||
|
</Suspense >
|
||||||
|
),
|
||||||
|
errorElement: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<ErrorBoundary />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
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
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
appTitleState,
|
appTitleState,
|
||||||
enableCustomArgsState,
|
enableCustomArgsState,
|
||||||
fileRenamingState,
|
fileRenamingState,
|
||||||
|
autoFileExtensionState,
|
||||||
formatSelectionState,
|
formatSelectionState,
|
||||||
languageState,
|
languageState,
|
||||||
languages,
|
languages,
|
||||||
@@ -61,6 +62,7 @@ export default function Settings() {
|
|||||||
const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
|
const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
|
||||||
const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
|
const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
|
||||||
const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
|
const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
|
||||||
|
const [autoFileExtension, setAutoFileExtension] = useAtom(autoFileExtensionState)
|
||||||
const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
|
const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
|
||||||
|
|
||||||
const [serverAddr, setServerAddr] = useAtom(serverAddressState)
|
const [serverAddr, setServerAddr] = useAtom(serverAddressState)
|
||||||
@@ -343,12 +345,30 @@ export default function Settings() {
|
|||||||
<Switch
|
<Switch
|
||||||
defaultChecked={fileRenaming}
|
defaultChecked={fileRenaming}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
|
if (fileRenaming) {
|
||||||
|
setAutoFileExtension(false)
|
||||||
|
}
|
||||||
setFileRenaming(state => !state)
|
setFileRenaming(state => !state)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={i18n.t('filenameOverrideOption')}
|
label={i18n.t('filenameOverrideOption')}
|
||||||
/>
|
/>
|
||||||
|
{
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
disabled={!fileRenaming}
|
||||||
|
checked={fileRenaming ? autoFileExtension : false}
|
||||||
|
defaultChecked={autoFileExtension}
|
||||||
|
onChange={() => {
|
||||||
|
setAutoFileExtension(state => !state)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={i18n.t('autoFileExtensionOption')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
157
frontend/src/views/Subscriptions.tsx
Normal file
157
frontend/src/views/Subscriptions.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Table, TableBody, TableCell, TableContainer,
|
||||||
|
TableHead, TablePagination, TableRow
|
||||||
|
} from '@mui/material'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||||
|
import SubscriptionsDialog from '../components/subscriptions/SubscriptionsDialog'
|
||||||
|
import SubscriptionsEditDialog from '../components/subscriptions/SubscriptionsEditDialog'
|
||||||
|
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import useFetch from '../hooks/useFetch'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { Subscription } from '../services/subscriptions'
|
||||||
|
import { PaginatedResponse } from '../types'
|
||||||
|
|
||||||
|
const SubscriptionsView: React.FC = () => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const [selectedSubscription, setSelectedSubscription] = useState<Subscription>()
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const [startId, setStartId] = useState(0)
|
||||||
|
const [limit, setLimit] = useState(9)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const { data: subs, fetcher: refecth } = useFetch<PaginatedResponse<Subscription[]>>(
|
||||||
|
`/subscriptions?id=${startId}&limit=${limit}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const deleteSubscription = async (id: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/subscriptions/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
() => refecth()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingBackdrop isLoading={!subs || isPending} />
|
||||||
|
|
||||||
|
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||||
|
|
||||||
|
<SubscriptionsEditDialog
|
||||||
|
subscription={selectedSubscription}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedSubscription(undefined)
|
||||||
|
refecth()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SubscriptionsDialog open={openDialog} onClose={() => {
|
||||||
|
setOpenDialog(s => !s)
|
||||||
|
refecth()
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{!subs || subs.data.length === 0 ?
|
||||||
|
<NoSubscriptions /> :
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||||
|
<Paper sx={{
|
||||||
|
p: 2.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '80vh',
|
||||||
|
}}>
|
||||||
|
<TableContainer component={Box}>
|
||||||
|
<Table sx={{ minWidth: '100%' }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="left">URL</TableCell>
|
||||||
|
<TableCell align="right">Params</TableCell>
|
||||||
|
<TableCell align="right">{i18n.t('cronExpressionLabel')}</TableCell>
|
||||||
|
<TableCell align="center">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody sx={{ mb: 'auto' }}>
|
||||||
|
{subs.data.map(x => (
|
||||||
|
<TableRow
|
||||||
|
key={x.id}
|
||||||
|
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||||
|
>
|
||||||
|
<TableCell>{x.url}</TableCell>
|
||||||
|
<TableCell align='right'>
|
||||||
|
{x.params}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align='right'>
|
||||||
|
{x.cron_expression}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align='center'>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
size='small'
|
||||||
|
sx={{ mr: 0.5 }}
|
||||||
|
onClick={() => setSelectedSubscription(x)}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
size='small'
|
||||||
|
onClick={() => startTransition(async () => await deleteSubscription(x.id))}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={-1}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_, p) => {
|
||||||
|
if (p < page) {
|
||||||
|
setPage(s => (s - 1 <= 0 ? 0 : s - 1))
|
||||||
|
setStartId(subs.first)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPage(s => s + 1)
|
||||||
|
setStartId(subs.next)
|
||||||
|
}}
|
||||||
|
rowsPerPage={limit}
|
||||||
|
rowsPerPageOptions={[9, 10, 25, 50, 100]}
|
||||||
|
onRowsPerPageChange={(e) => { setLimit(parseInt(e.target.value)) }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Container>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionsView
|
||||||
29
go.mod
29
go.mod
@@ -4,34 +4,29 @@ go 1.23
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0
|
github.com/coreos/go-oidc/v3 v3.12.0
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/chi/v5 v5.2.0
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
golang.org/x/oauth2 v0.23.0
|
github.com/robfig/cron/v3 v3.0.0
|
||||||
golang.org/x/sync v0.8.0
|
golang.org/x/oauth2 v0.25.0
|
||||||
golang.org/x/sys v0.25.0
|
golang.org/x/sync v0.10.0
|
||||||
|
golang.org/x/sys v0.29.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.33.1
|
modernc.org/sqlite v1.34.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
|
||||||
github.com/kr/pretty v0.1.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/crypto v0.27.0 // indirect
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
modernc.org/libc v1.61.11 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
modernc.org/memory v1.8.2 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
|
||||||
modernc.org/memory v1.8.0 // indirect
|
|
||||||
modernc.org/strutil v1.2.0 // indirect
|
|
||||||
modernc.org/token v1.1.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
90
go.sum
90
go.sum
@@ -1,13 +1,13 @@
|
|||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
|
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
|
||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
|
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
|
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
|
||||||
@@ -22,13 +22,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
@@ -37,51 +30,50 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
|
||||||
|
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||||
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
|
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
modernc.org/ccgo/v4 v4.23.15 h1:wFDan71KnYqeHz4eF63vmGE6Q6Pc0PUGDpP0PRMYjDc=
|
||||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
modernc.org/ccgo/v4 v4.23.15/go.mod h1:nJX30dks/IWuBOnVa7VRii9Me4/9TZ1SC9GNtmARTy0=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
modernc.org/gc/v2 v2.6.2 h1:YBXi5Kqp6aCK3fIxwKQ3/fErvawVKwjOLItxj1brGds=
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/gc/v2 v2.6.2/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
modernc.org/libc v1.61.11 h1:6sZG8uB6EMMG7iTLPTndi8jyTdgAQNIeLGjCFICACZw=
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/libc v1.61.11/go.mod h1:HHX+srFdn839oaJRd0W8hBM3eg+mieyZCAjWwB08/nM=
|
||||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ func Migrate(ctx context.Context, db *sql.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
url VARCHAR(2048) UNIQUE NOT NULL,
|
||||||
|
params TEXT NOT NULL,
|
||||||
|
cron TEXT
|
||||||
|
)`,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if lockFileExists() {
|
if lockFileExists() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -11,6 +12,8 @@ import (
|
|||||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var memDbEvents = make(chan *Process)
|
||||||
|
|
||||||
// In-Memory Thread-Safe Key-Value Storage with optional persistence
|
// In-Memory Thread-Safe Key-Value Storage with optional persistence
|
||||||
type MemoryDB struct {
|
type MemoryDB struct {
|
||||||
table map[string]*Process
|
table map[string]*Process
|
||||||
@@ -144,3 +147,12 @@ func (m *MemoryDB) Restore(mq *MessageQueue) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MemoryDB) EventListener() {
|
||||||
|
for p := range memDbEvents {
|
||||||
|
if p.AutoRemove {
|
||||||
|
slog.Info("compacting MemoryDB", slog.String("id", p.Id))
|
||||||
|
m.Delete(p.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type Process struct {
|
|||||||
Id string
|
Id string
|
||||||
Url string
|
Url string
|
||||||
Livestream bool
|
Livestream bool
|
||||||
|
AutoRemove bool
|
||||||
Params []string
|
Params []string
|
||||||
Info DownloadInfo
|
Info DownloadInfo
|
||||||
Progress DownloadProgress
|
Progress DownloadProgress
|
||||||
@@ -253,6 +254,8 @@ func (p *Process) Complete() {
|
|||||||
slog.String("id", p.getShortId()),
|
slog.String("id", p.getShortId()),
|
||||||
slog.String("url", p.Url),
|
slog.String("url", p.Url),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
memDbEvents <- p
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill a process and remove it from the memory
|
// Kill a process and remove it from the memory
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
func (s *Service) GetVersion(ctx context.Context) (string, string, error) {
|
func (s *Service) GetVersion(ctx context.Context) (string, string, error) {
|
||||||
//TODO: load from realease properties file, or anything else outside code
|
//TODO: load from realease properties file, or anything else outside code
|
||||||
const CURRENT_RPC_VERSION = "3.2.3"
|
const CURRENT_RPC_VERSION = "3.2.5"
|
||||||
|
|
||||||
result := make(chan string, 1)
|
result := make(chan string, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import (
|
|||||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/rest"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/rest"
|
||||||
ytdlpRPC "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/rpc"
|
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/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/user"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/user"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -107,6 +109,7 @@ func RunBlocking(rc *RunConfig) {
|
|||||||
}
|
}
|
||||||
mq.SetupConsumers()
|
mq.SetupConsumers()
|
||||||
go mdb.Restore(mq)
|
go mdb.Restore(mq)
|
||||||
|
go mdb.EventListener()
|
||||||
|
|
||||||
lm := livestream.NewMonitor(mq, mdb)
|
lm := livestream.NewMonitor(mq, mdb)
|
||||||
go lm.Schedule()
|
go lm.Schedule()
|
||||||
@@ -151,6 +154,9 @@ func RunBlocking(rc *RunConfig) {
|
|||||||
func newServer(c serverConfig) *http.Server {
|
func newServer(c serverConfig) *http.Server {
|
||||||
archiver.Register(c.db)
|
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)
|
service := ytdlpRPC.Container(c.mdb, c.mq, c.lm)
|
||||||
rpc.Register(service)
|
rpc.Register(service)
|
||||||
|
|
||||||
@@ -226,6 +232,9 @@ func newServer(c serverConfig) *http.Server {
|
|||||||
// Status
|
// Status
|
||||||
r.Route("/status", status.ApplyRouter(c.mdb))
|
r.Route("/status", status.ApplyRouter(c.mdb))
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter())
|
||||||
|
|
||||||
return &http.Server{Handler: r}
|
return &http.Server{Handler: r}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
140
server/subscription/service/service.go
Normal file
140
server/subscription/service/service.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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 {
|
||||||
|
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
|
||||||
117
server/subscription/task/runner.go
Normal file
117
server/subscription/task/runner.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
Recoverer()
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskPair struct {
|
||||||
|
Schedule cron.Schedule
|
||||||
|
Subscription *domain.Subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
type CronTaskRunner struct {
|
||||||
|
mq *internal.MessageQueue
|
||||||
|
db *internal.MemoryDB
|
||||||
|
|
||||||
|
tasks chan taskPair
|
||||||
|
errors chan error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCronTaskRunner(mq *internal.MessageQueue, db *internal.MemoryDB) TaskRunner {
|
||||||
|
return &CronTaskRunner{
|
||||||
|
mq: mq,
|
||||||
|
db: db,
|
||||||
|
tasks: make(chan taskPair),
|
||||||
|
errors: make(chan error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandTemplate = "-I1 --flat-playlist --print webpage_url $1"
|
||||||
|
|
||||||
|
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 := taskPair{
|
||||||
|
Schedule: schedule,
|
||||||
|
Subscription: subcription,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.tasks <- job
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTaskRunner) Spawner(ctx context.Context) {
|
||||||
|
for task := range t.tasks {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
slog.Info("fetching latest video for channel", slog.String("channel", task.Subscription.URL))
|
||||||
|
|
||||||
|
fetcherParams := strings.Split(strings.Replace(commandTemplate, "$1", task.Subscription.URL, 1), " ")
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
config.Instance().DownloaderPath,
|
||||||
|
fetcherParams...,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.errors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latestChannelURL := string(bytes.Trim(stdout, "\n"))
|
||||||
|
|
||||||
|
p := &internal.Process{
|
||||||
|
Url: latestChannelURL,
|
||||||
|
Params: append(argsSplitterRe.FindAllString(task.Subscription.Params, 1), []string{
|
||||||
|
"--download-archive",
|
||||||
|
filepath.Join(config.Instance().Dir(), "archive.txt"),
|
||||||
|
}...),
|
||||||
|
AutoRemove: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.db.Set(p)
|
||||||
|
t.mq.Publish(p)
|
||||||
|
|
||||||
|
nextSchedule := time.Until(task.Schedule.Next(time.Now()))
|
||||||
|
|
||||||
|
slog.Info(
|
||||||
|
"cron task runner next schedule",
|
||||||
|
slog.String("url", task.Subscription.URL),
|
||||||
|
slog.Any("duration", nextSchedule),
|
||||||
|
)
|
||||||
|
|
||||||
|
time.Sleep(nextSchedule)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTaskRunner) Recoverer() {
|
||||||
|
panic("Unimplemented")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user