Compare commits

...

18 Commits

Author SHA1 Message Date
54771b2d78 resuse the message queue for livestream downloading 2024-08-23 18:52:13 +02:00
fceb36c723 code refactoring: cancellation signal for stdout parsers 2024-08-23 11:54:10 +02:00
c4075fb640 ready for 3.2.0 2024-08-21 11:43:55 +02:00
Aaron Gershman
aa8191b0cd filemame to filename (#182)
Typo on filemame fixed to filename
2024-08-21 11:29:10 +02:00
a6626973ac apply loading backdrop when loading livestreams 2024-08-21 11:23:34 +02:00
79f1473c6a fixed livestream process not properly killed 2024-08-21 11:16:44 +02:00
Marco Piovanello
b76f2b72be Update README.md 2024-08-20 20:31:39 +02:00
8f2d9eaf6e code refactoring 2024-08-20 20:29:32 +02:00
ja49619
c51f320a6f Update i18n.yaml (#181)
Update translation for i18n.yaml to RU language
2024-08-20 20:14:27 +02:00
8b26bf513f code refactoring 2024-08-20 19:11:54 +02:00
25210ccc22 code refactoring 2024-08-20 19:04:10 +02:00
3205711bb1 improved livestream waiting 2024-08-20 18:50:42 +02:00
92e3fd994e code refactoring / fix typos 2024-08-20 09:42:04 +02:00
01e9da61eb code refactoring 2024-08-19 22:24:38 +02:00
Marco Piovanello
fd5e62e23b Feat livestream support (#180)
* experimental livestrea support

* test livestream

* update wait time detection

* update livestream functions

* persist and restore livestreams monitor session

* fan-in logging

* deps update

* added live time display

* livestream monitor prototype

* changed to default logger instead of passing *slog.Logger everywhere

* code refactoring, comments
2024-08-19 22:08:09 +02:00
a64798644a code refactoring
fixed bad escape in i18n.yaml
2024-08-19 10:25:25 +02:00
Emanuel Johnson Godin
b7511eb064 i18n: add swedish (#179)
* i18n: add swedish

* Fix spelling mistakes and minor rewording
2024-08-19 10:08:50 +02:00
Marco Piovanello
16f8f74f9b Delete frontend/yarn.lock 2024-08-16 14:17:14 +02:00
35 changed files with 1157 additions and 3337 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.tsx linguist-detectable=false

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ frontend/.pnp.cjs
frontend/.pnp.loader.mjs frontend/.pnp.loader.mjs
frontend/.yarn/install-state.gz frontend/.yarn/install-state.gz
.db.lock .db.lock
livestreams.dat

View File

@@ -1,8 +1,3 @@
> [!IMPORTANT]
> Major frontend refactoring in progress.
> I won't add features or fix minor issues until completition.
---
# yt-dlp Web UI # yt-dlp Web UI
A not so terrible web ui for yt-dlp. A not so terrible web ui for yt-dlp.

View File

@@ -1,6 +1,6 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "3.1.0", "version": "3.2.0",
"description": "Frontend compontent of yt-dlp-webui", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -2,6 +2,7 @@ import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive' import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard' import Dashboard from '@mui/icons-material/Dashboard'
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'
@@ -121,6 +122,19 @@ export default function Layout() {
<ListItemText primary={i18n.t('archiveButtonLabel')} /> <ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton> </ListItemButton>
</Link> </Link>
<Link to={'/monitor'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<LiveTvIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/log'} style={ <Link to={'/log'} style={
{ {
textDecoration: 'none', textDecoration: 'none',

View File

@@ -24,7 +24,7 @@ languages:
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
customFilename: Custom filemame (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)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
@@ -54,6 +54,16 @@ languages:
rpcPollingTimeTitle: RPC polling time rpcPollingTimeTitle: RPC polling time
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side) rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
templatesReloadInfo: To register a new template it might need a page reload. templatesReloadInfo: To register a new template it might need a page reload.
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
german: german:
urlInput: Video URL urlInput: Video URL
statusTitle: Status statusTitle: Status
@@ -78,7 +88,7 @@ languages:
overridesAnchor: Überschreibungen overridesAnchor: Überschreibungen
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
customFilename: Custom filemame (leave blank to use default) customFilename: Custom filename (leave blank to use default)
customPath: Benutzerdefinierter Pfad customPath: Benutzerdefinierter Pfad
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung) customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
customArgsInput: Benutzerdefinierte yt-dlp Argumente customArgsInput: Benutzerdefinierte yt-dlp Argumente
@@ -104,6 +114,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
french: french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut statusTitle: Statut
@@ -156,6 +176,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
italian: italian:
urlInput: URL Video (uno per linea) urlInput: URL Video (uno per linea)
statusTitle: Stato statusTitle: Stato
@@ -179,7 +209,7 @@ languages:
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
customFilename: Custom filemame (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)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
@@ -205,6 +235,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
chinese: chinese:
urlInput: 视频 URL urlInput: 视频 URL
statusTitle: 状态 statusTitle: 状态
@@ -255,6 +295,16 @@ languages:
logsTitle: '日志' logsTitle: '日志'
awaitingLogs: '正在等待日志…' awaitingLogs: '正在等待日志…'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -303,6 +353,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
russian: russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус statusTitle: Статус
@@ -334,23 +394,33 @@ languages:
splashText: Нет активных загрузок splashText: Нет активных загрузок
archiveTitle: Архив archiveTitle: Архив
clipboardAction: URL скопирован в буфер обмена clipboardAction: URL скопирован в буфер обмена
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Находится за обратным прокси
newDownloadButton: New download newDownloadButton: Новая загрузка
homeButtonLabel: Home homeButtonLabel: Home
archiveButtonLabel: Archive archiveButtonLabel: Архив
settingsButtonLabel: Settings settingsButtonLabel: Настройки
rpcAuthenticationLabel: RPC authentication rpcAuthenticationLabel: RPC-аутентификация
themeTogglerLabel: Theme toggler themeTogglerLabel: Переключить тему
loadingLabel: Loading... loadingLabel: Загрузка...
appTitle: App title appTitle: Название приложения
savedTemplates: Saved templates savedTemplates: Сохраненные шаблоны
templatesEditor: Templates editor templatesEditor: Редактор шаблонов
templatesEditorNameLabel: Template name templatesEditorNameLabel: Имя шаблона
templatesEditorContentLabel: Template content templatesEditorContentLabel: Содержание шаблона
logsTitle: 'Logs' logsTitle: 'Логи'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Ожидание логов...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Скачать файлы в zip архиве'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -374,7 +444,7 @@ languages:
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
customFilename: Custom filemame (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)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
@@ -399,6 +469,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -448,6 +528,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
catalan: catalan:
urlInput: URL de YouTube o d'un altre servei compatible urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat statusTitle: Estat
@@ -496,6 +586,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
ukrainian: ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус statusTitle: Статус
@@ -544,6 +644,16 @@ languages:
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
polish: polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status statusTitle: Status
@@ -591,4 +701,78 @@ languages:
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs' logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...' awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive' bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
swedish:
urlInput: Videolänk (en per rad)
statusTitle: Status
statusReady: Redo
selectFormatButton: Välj format
startButton: Start
abortAllButton: Avbryt alla
updateBinButton: Uppdatera yt-dlp
darkThemeButton: Mörkt tema
lightThemeButton: Ljust tema
settingsAnchor: Inställningar
serverAddressTitle: Serveraddress
serverPortTitle: Port
extractAudioCheckbox: Extrahera ljud
noMTimeCheckbox: Lägg inte till info om när filen senast modifierades
bgReminder: När du stänger denna sida så kommer nedladdningen att fortsätta i bakgrunden.
toastConnected: 'Ansluten till '
toastUpdated: Uppdaterade yt-dlp!
formatSelectionEnabler: Tillåt val av ljud- och bildformat
themeSelect: 'Tema'
languageSelect: 'Språk'
overridesAnchor: Överskrivningar
pathOverrideOption: Tillåt överskrivning av filsökvägen
filenameOverrideOption: Tillåt överskrivning av filnamn
customFilename: Eget filnamn (lämna blankt för standardnamn)
customPath: Egen filsökväg
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
customArgsInput: Egna yt-dlp-argument
rpcConnErr: Ett fel inträffade vid anslutning till RPC-server
splashText: Inga pågående nedladdningar
archiveTitle: Arkiv
clipboardAction: Kopierade länken
playlistCheckbox: Ladda ner spellista (detta kommer ta did, efter start så kan du stänga detta fönster)
restartAppMessage: En sidomladdning behövs innan förändringen får effekt
servedFromReverseProxyCheckbox: Servern befinner sig bakom en omvänd proxy
urlBase: "URL-bas, måste anges när en omvänd proxy används. Standardinställning: lämna blank"
newDownloadButton: Ny nedladdning
homeButtonLabel: Hem
archiveButtonLabel: Arkiv
settingsButtonLabel: Inställningar
rpcAuthenticationLabel: RPC-Autentisering
themeTogglerLabel: Tema-knapp
loadingLabel: Laddar...
appTitle: Apptitel
savedTemplates: Sparade mallar
templatesEditor: Mallredigerare
templatesEditorNameLabel: Namn
templatesEditorContentLabel: Innehåll
logsTitle: 'Loggar'
awaitingLogs: 'Väntar på loggar...'
bulkDownload: 'Ladda ner filer i ett zip-arkiv'
rpcPollingTimeTitle: Frekvens av RPC-uppdateringar
rpcPollingTimeDescription: En högre frekvens kräver mer CPU-resurser för både server och klient
templatesReloadInfo: För att registrera en ny mall så kan en sidomladdning krävas.
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
livestreamExperimentalWarning: This feature is still experimental. Something might break!

View File

@@ -35,7 +35,8 @@ const Footer: React.FC = () => {
display: 'flex', gap: 1, justifyContent: 'space-between' display: 'flex', gap: 1, justifyContent: 'space-between'
}}> }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<Chip label="RPC v3.1.0" variant="outlined" size="small" /> {/* TODO: make it dynamic */}
<Chip label="RPC v3.2.0" variant="outlined" size="small" />
<VersionIndicator /> <VersionIndicator />
</div> </div>
<div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}> <div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}>

View File

@@ -1,10 +1,10 @@
import { Backdrop, CircularProgress } from '@mui/material' import { Backdrop, CircularProgress } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
const LoadingBackdrop: React.FC = () => { type Props = {
const isLoading = useRecoilValue(loadingAtom) isLoading: boolean
}
const LoadingBackdrop: React.FC<Props> = ({ isLoading }) => {
return ( return (
<Backdrop <Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }} sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}

View File

@@ -0,0 +1,125 @@
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 { forwardRef, useState } from 'react'
import { useToast } from '../../hooks/toast'
import { useI18n } from '../../hooks/useI18n'
import { useRPC } from '../../hooks/useRPC'
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 LivestreamDialog: React.FC<Props> = ({ open, onClose }) => {
const [livestreamURL, setLivestreamURL] = useState('')
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const exec = (url: string) => client.execLivestream(url)
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">
Livestream monitor
</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>
<Grid item xs={12} mb={2}>
<Alert severity="info">
{i18n.t('livestreamDownloadInfo')}
</Alert>
<Alert severity="warning" sx={{ mt: 1 }}>
{i18n.t('livestreamExperimentalWarning')}
</Alert>
</Grid>
<Grid item xs={12}>
<TextField
multiline
fullWidth
label={i18n.t('livestreamURLInput')}
variant="outlined"
onChange={(e) => setLivestreamURL(e.target.value)}
/>
</Grid>
<Grid item>
<Button
sx={{ mt: 2 }}
variant="contained"
disabled={livestreamURL === ''}
onClick={() => {
exec(livestreamURL)
onClose()
pushMessage(`Monitoring ${livestreamURL}`, 'info')
}}
>
{i18n.t('startButton')}
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Container>
</Box>
</Dialog>
)
}
export default LivestreamDialog

View File

@@ -0,0 +1,34 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material'
import { useI18n } from '../../hooks/useI18n'
type Props = {
onOpen: () => void
onStopAll: () => void
}
const LivestreamSpeedDial: React.FC<Props> = ({ onOpen, onStopAll }) => {
const { i18n } = useI18n()
return (
<SpeedDial
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={onStopAll}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={i18n.t('newDownloadButton')}
onClick={onOpen}
/>
</SpeedDial>
)
}
export default LivestreamSpeedDial

View File

@@ -0,0 +1,38 @@
import LiveTvIcon from '@mui/icons-material/LiveTv'
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 NoLivestreams() {
const { i18n } = useI18n()
return (
<FlexContainer>
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
<SvgIcon sx={{ fontSize: '200px' }}>
<LiveTvIcon />
</SvgIcon>
</Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
No livestreams monitored
</Title>
</FlexContainer>
)
}

View File

@@ -1,5 +1,5 @@
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types' import type { DLMetadata, LiveStreamProgress, RPCRequest, RPCResponse, RPCResult } from '../types'
import { WebSocketSubject, webSocket } from 'rxjs/webSocket' import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
@@ -160,9 +160,32 @@ export class RPCClient {
}) })
} }
public updateExecutable() { public execLivestream(url: string) {
return this.sendHTTP({ return this.sendHTTP({
method: 'Service.UpdateExecutable', method: 'Service.ExecLivestream',
params: [{
URL: url
}]
})
}
public progressLivestream() {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.ProgressLivestream',
params: []
})
}
public killLivestream(url: string) {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.KillLivestream',
params: [url]
})
}
public killAllLivestream() {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.KillAllLivestream',
params: [] params: []
}) })
} }

View File

@@ -8,6 +8,7 @@ const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login')) const Login = lazy(() => import('./views/Login'))
const Archive = lazy(() => import('./views/Archive')) const Archive = lazy(() => import('./views/Archive'))
const Settings = lazy(() => import('./views/Settings')) const Settings = lazy(() => import('./views/Settings'))
const LiveStream = lazy(() => import('./views/Livestream'))
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary')) const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
@@ -74,6 +75,14 @@ export const router = createHashRouter([
</Suspense > </Suspense >
) )
}, },
{
path: '/monitor',
element: (
<Suspense fallback={<CircularProgress />}>
<LiveStream />
</Suspense >
)
},
] ]
}, },
]) ])

View File

@@ -9,6 +9,10 @@ export type RPCMethods =
| "Service.ExecPlaylist" | "Service.ExecPlaylist"
| "Service.DirectoryTree" | "Service.DirectoryTree"
| "Service.UpdateExecutable" | "Service.UpdateExecutable"
| "Service.ExecLivestream"
| "Service.ProgressLivestream"
| "Service.KillLivestream"
| "Service.KillAllLivestream"
export type RPCRequest = { export type RPCRequest = {
method: RPCMethods method: RPCMethods
@@ -96,4 +100,17 @@ export type CustomTemplate = {
id: string id: string
name: string name: string
content: string content: string
} }
export enum LiveStreamStatus {
WAITING,
IN_PROGRESS,
COMPLETED,
ERRORED
}
export type LiveStreamProgress = Record<string, {
Status: LiveStreamStatus
WaitTime: string
LiveDate: string
}>

View File

@@ -1,15 +1,19 @@
import { import {
Container Container
} from '@mui/material' } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
import Downloads from '../components/Downloads' import Downloads from '../components/Downloads'
import HomeActions from '../components/HomeActions' import HomeActions from '../components/HomeActions'
import LoadingBackdrop from '../components/LoadingBackdrop' import LoadingBackdrop from '../components/LoadingBackdrop'
import Splash from '../components/Splash' import Splash from '../components/Splash'
export default function Home() { export default function Home() {
const isLoading = useRecoilValue(loadingAtom)
return ( return (
<Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}> <Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}>
<LoadingBackdrop /> <LoadingBackdrop isLoading={isLoading} />
<Splash /> <Splash />
<Downloads /> <Downloads />
<HomeActions /> <HomeActions />

View File

@@ -0,0 +1,134 @@
import {
Box,
Button,
Chip,
Container,
Paper,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
} from '@mui/material'
import { useState } from 'react'
import { interval } from 'rxjs'
import LivestreamDialog from '../components/livestream/LivestreamDialog'
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
import NoLivestreams from '../components/livestream/NoLivestreams'
import LoadingBackdrop from '../components/LoadingBackdrop'
import { useSubscription } from '../hooks/observable'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { LiveStreamProgress, LiveStreamStatus } from '../types'
const LiveStreamMonitorView: React.FC = () => {
const { i18n } = useI18n()
const { client } = useRPC()
const [progress, setProgress] = useState<LiveStreamProgress>()
const [openDialog, setOpenDialog] = useState(false)
useSubscription(interval(1000), () => {
client
.progressLivestream()
.then(r => setProgress(r.result))
})
const formatMicro = (microseconds: number) => {
const ms = microseconds / 1_000_000
let s = ms / 1000
const hr = s / 3600
s %= 3600
const mt = s / 60
s %= 60
// huh?
const ss = (Math.abs(s - 1)).toFixed(0).padStart(2, '0')
const mts = mt.toFixed(0).padStart(2, '0')
const hrs = hr.toFixed(0).padStart(2, '0')
return `${hrs}:${mts}:${ss}`
}
const mapStatusToChip = (status: LiveStreamStatus): React.ReactNode => {
switch (status) {
case LiveStreamStatus.WAITING:
return <Chip label='Waiting/Wait start' color='warning' size='small' />
case LiveStreamStatus.IN_PROGRESS:
return <Chip label='Downloading' color='primary' size='small' />
case LiveStreamStatus.COMPLETED:
return <Chip label='Completed' color='success' size='small' />
case LiveStreamStatus.ERRORED:
return <Chip label='Errored' color='error' size='small' />
default:
return <Chip label='Unknown state' color='secondary' size='small' />
}
}
const stopAll = () => client.killAllLivestream()
const stop = (url: string) => client.killLivestream(url)
return (
<>
<LoadingBackdrop isLoading={!progress} />
<LivestreamSpeedDial onOpen={() => setOpenDialog(s => !s)} onStopAll={stopAll} />
<LivestreamDialog open={openDialog} onClose={() => setOpenDialog(s => !s)} />
{!progress || Object.keys(progress).length === 0 ?
<NoLivestreams /> :
<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>{i18n.t('livestreamURLInput')}</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right">Time to live</TableCell>
<TableCell align="right">Starts on</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{progress && Object.keys(progress).map(k => (
<TableRow
key={k}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell>{k}</TableCell>
<TableCell align='right'>
{mapStatusToChip(progress[k].Status)}
</TableCell>
<TableCell align='right'>
{progress[k].Status === LiveStreamStatus.WAITING
? formatMicro(Number(progress[k].WaitTime))
: "-"
}
</TableCell>
<TableCell align='right'>
{progress[k].Status === LiveStreamStatus.WAITING
? new Date(progress[k].LiveDate).toLocaleString()
: "-"
}
</TableCell>
<TableCell align='right'>
<Button variant='contained' size='small' onClick={() => stop(k)}>
Stop
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Container>}
</>
)
}
export default LiveStreamMonitorView

File diff suppressed because it is too large Load Diff

View File

@@ -158,7 +158,6 @@ func SendFile(w http.ResponseWriter, r *http.Request) {
root := config.Instance().DownloadPath root := config.Instance().DownloadPath
// TODO: further path / file validations
if strings.Contains(filepath.Dir(filename), root) { if strings.Contains(filepath.Dir(filename), root) {
http.ServeFile(w, r, filename) http.ServeFile(w, r, filename)
return return

View File

@@ -0,0 +1,211 @@
package livestream
import (
"bufio"
"errors"
"io"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
const (
waiting = iota
inProgress
completed
errored
)
// Defines a generic livestream.
// A livestream is identified by its url.
type LiveStream struct {
url string
proc *os.Process // used to manually kill the yt-dlp process
status int // whether is monitoring or completed
done chan *LiveStream // where to signal the completition
waitTimeChan chan time.Duration // time to livestream start
waitTime time.Duration
liveDate time.Time
mq *internal.MessageQueue
db *internal.MemoryDB
}
func New(url string, done chan *LiveStream, mq *internal.MessageQueue, db *internal.MemoryDB) *LiveStream {
return &LiveStream{
url: url,
done: done,
status: waiting,
waitTime: time.Second * 0,
waitTimeChan: make(chan time.Duration),
mq: mq,
db: db,
}
}
// Start the livestream monitoring process, once completion signals on the done channel
func (l *LiveStream) Start() error {
cmd := exec.Command(
config.Instance().DownloaderPath,
l.url,
"--wait-for-video", "30", // wait for the stream to be live and recheck every 10 secs
"--no-colors", // no ansi color fuzz
"--simulate",
"--newline",
"--paths", config.Instance().DownloadPath,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
l.status = errored
return err
}
defer stdout.Close()
if err := cmd.Start(); err != nil {
l.status = errored
return err
}
l.proc = cmd.Process
l.status = waiting
// Start monitoring when the livestream is goin to be live.
// If already live do nothing.
go l.monitorStartTime(stdout)
// Wait to the simulated download process to finish.
cmd.Wait()
// Set the job as completed and notify the parent the completion.
l.status = completed
l.done <- l
// Send the started livestream to the message queue! :D
p := &internal.Process{Url: l.url, Livestream: true}
l.db.Set(p)
l.mq.Publish(p)
return nil
}
func (l *LiveStream) monitorStartTime(r io.Reader) {
// yt-dlp shows the time in the stdout
scanner := bufio.NewScanner(r)
defer func() {
l.status = inProgress
close(l.waitTimeChan)
}()
// however the time to live is not shown in a new line (and atm there's nothing to do about)
// use a custom split funciton to set the line separator to \r instead of \r\n or \n
scanner.Split(stdoutSplitFunc)
waitTimeScanner := func() {
for scanner.Scan() {
// l.log <- scanner.Bytes()
// if this substring is in the current line the download is starting,
// no need to monitor the time to live.
//TODO: silly
if !strings.Contains(scanner.Text(), "Remaining time until next attempt") {
return
}
parts := strings.Split(scanner.Text(), ": ")
if len(parts) < 2 {
continue
}
startsIn := parts[1]
parsed, err := parseTimeSpan(startsIn)
if err != nil {
continue
}
l.liveDate = parsed
//TODO: check if using channels is stupid or not
// l.waitTimeChan <- time.Until(start)
l.waitTime = time.Until(parsed)
}
}
const TRIES = 5
/*
if it's waiting a livestream the 5th line will indicate the time to live
its a dumb and not robust method.
example:
[youtube] Extracting URL: https://www.youtube.com/watch?v=IQVbGfVVjgY
[youtube] IQVbGfVVjgY: Downloading webpage
[youtube] IQVbGfVVjgY: Downloading ios player API JSON
[youtube] IQVbGfVVjgY: Downloading web creator player API JSON
WARNING: [youtube] This live event will begin in 27 minutes. <- STDERR, ignore
[wait] Waiting for 00:27:15 - Press Ctrl+C to try now <- 5th line
*/
for range TRIES {
scanner.Scan()
if strings.Contains(scanner.Text(), "Waiting for") {
waitTimeScanner()
}
}
}
func (l *LiveStream) WaitTime() <-chan time.Duration {
return l.waitTimeChan
}
// Kills a livestream process and signal its completition
func (l *LiveStream) Kill() error {
l.done <- l
if l.proc != nil {
return l.proc.Kill()
}
return errors.New("nil yt-dlp process")
}
// Parse the timespan returned from yt-dlp (time to live)
//
// parsed := parseTimeSpan("76:12:15")
// fmt.Println(parsed) // 2024-07-21 13:59:59.634781 +0200 CEST
func parseTimeSpan(timeStr string) (time.Time, error) {
parts := strings.Split(timeStr, ":")
hh, err := strconv.Atoi(parts[0])
if err != nil {
return time.Time{}, err
}
mm, err := strconv.Atoi(parts[1])
if err != nil {
return time.Time{}, err
}
ss, err := strconv.Atoi(parts[2])
if err != nil {
return time.Time{}, err
}
dd := 0
if hh > 24 {
dd = hh / 24
hh = hh % 24
}
start := time.Now()
start = start.AddDate(0, 0, dd)
start = start.Add(time.Duration(hh) * time.Hour)
start = start.Add(time.Duration(mm) * time.Minute)
start = start.Add(time.Duration(ss) * time.Second)
return start, nil
}

View File

@@ -0,0 +1,36 @@
package livestream
import (
"testing"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
func setupTest() {
config.Instance().DownloaderPath = "yt-dlp"
}
func TestLivestream(t *testing.T) {
setupTest()
done := make(chan *LiveStream)
ls := New("https://www.youtube.com/watch?v=LSm1daKezcE", done, &internal.MessageQueue{}, &internal.MemoryDB{})
go ls.Start()
time.AfterFunc(time.Second*20, func() {
ls.Kill()
})
for {
select {
case wt := <-ls.WaitTime():
t.Log(wt)
case <-done:
t.Log("done")
return
}
}
}

View File

@@ -0,0 +1,117 @@
package livestream
import (
"encoding/gob"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
type Monitor struct {
db *internal.MemoryDB // where the just started livestream will be published
mq *internal.MessageQueue // where the just started livestream will be published
streams map[string]*LiveStream // keeps track of the livestreams
done chan *LiveStream // to signal individual processes completition
}
func NewMonitor(mq *internal.MessageQueue, db *internal.MemoryDB) *Monitor {
return &Monitor{
mq: mq,
db: db,
streams: make(map[string]*LiveStream),
done: make(chan *LiveStream),
}
}
// Detect each livestream completition, if done detach it from the monitor.
func (m *Monitor) Schedule() {
for l := range m.done {
delete(m.streams, l.url)
}
}
func (m *Monitor) Add(url string) {
ls := New(url, m.done, m.mq, m.db)
go ls.Start()
m.streams[url] = ls
}
func (m *Monitor) Remove(url string) error {
return m.streams[url].Kill()
}
func (m *Monitor) RemoveAll() error {
for _, v := range m.streams {
if err := v.Kill(); err != nil {
return err
}
}
return nil
}
func (m *Monitor) Status() LiveStreamStatus {
status := make(LiveStreamStatus)
for k, v := range m.streams {
// wt, ok := <-v.WaitTime()
// if !ok {
// continue
// }
status[k] = struct {
Status int
WaitTime time.Duration
LiveDate time.Time
}{
Status: v.status,
WaitTime: v.waitTime,
LiveDate: v.liveDate,
}
}
return status
}
// Persist the monitor current state to a file.
// The file is located in the configured config directory
func (m *Monitor) Persist() error {
fd, err := os.Create(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
if err != nil {
return err
}
defer fd.Close()
slog.Debug("persisting livestream monitor state")
return gob.NewEncoder(fd).Encode(m.streams)
}
// Restore a saved state and resume the monitored livestreams
func (m *Monitor) Restore() error {
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
if err != nil {
return err
}
defer fd.Close()
restored := make(map[string]*LiveStream)
if err := gob.NewDecoder(fd).Decode(&restored); err != nil {
return err
}
for k := range restored {
m.Add(k)
}
slog.Debug("restored livestream monitor state")
return nil
}

View File

@@ -0,0 +1 @@
package livestream

View File

@@ -0,0 +1,11 @@
package livestream
import "time"
type LiveStreamStatus = map[string]Status
type Status = struct {
Status int
WaitTime time.Duration
LiveDate time.Time
}

View File

@@ -0,0 +1,16 @@
package livestream
import "bufio"
var stdoutSplitFunc = func(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i := 0; i < len(data); i++ {
if data[i] == '\r' || data[i] == '\n' {
return i + 1, data[:i], nil
}
}
if !atEOF {
return 0, nil, nil
}
return 0, data, bufio.ErrFinalToken
}

View File

@@ -3,7 +3,6 @@ package internal
import ( import (
"encoding/gob" "encoding/gob"
"errors" "errors"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -92,7 +91,7 @@ func (m *MemoryDB) Persist() error {
} }
// Restore a persisted state // Restore a persisted state
func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) { func (m *MemoryDB) Restore(mq *MessageQueue) {
fd, err := os.Open("session.dat") fd, err := os.Open("session.dat")
if err != nil { if err != nil {
return return
@@ -112,7 +111,6 @@ func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) {
Progress: proc.Progress, Progress: proc.Progress,
Output: proc.Output, Output: proc.Output,
Params: proc.Params, Params: proc.Params,
Logger: logger,
} }
m.table.Store(proc.Id, restored) m.table.Store(proc.Id, restored)

View File

@@ -15,14 +15,13 @@ const queueName = "process:pending"
type MessageQueue struct { type MessageQueue struct {
concurrency int concurrency int
eventBus evbus.Bus eventBus evbus.Bus
logger *slog.Logger
} }
// Creates a new message queue. // Creates a new message queue.
// By default it will be created with a size equals to nthe number of logical // By default it will be created with a size equals to nthe number of logical
// CPU cores -1. // CPU cores -1.
// The queue size can be set via the qs flag. // The queue size can be set via the qs flag.
func NewMessageQueue(l *slog.Logger) (*MessageQueue, error) { func NewMessageQueue() (*MessageQueue, error) {
qs := config.Instance().QueueSize qs := config.Instance().QueueSize
if qs <= 0 { if qs <= 0 {
@@ -32,7 +31,6 @@ func NewMessageQueue(l *slog.Logger) (*MessageQueue, error) {
return &MessageQueue{ return &MessageQueue{
concurrency: qs, concurrency: qs,
eventBus: evbus.New(), eventBus: evbus.New(),
logger: l,
}, nil }, nil
} }
@@ -55,21 +53,24 @@ func (m *MessageQueue) downloadConsumer() {
sem := semaphore.NewWeighted(int64(m.concurrency)) sem := semaphore.NewWeighted(int64(m.concurrency))
m.eventBus.SubscribeAsync(queueName, func(p *Process) { m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.Background(), 1) sem.Acquire(context.Background(), 1)
defer sem.Release(1) defer sem.Release(1)
m.logger.Info("received process from event bus", slog.Info("received process from event bus",
slog.String("bus", queueName), slog.String("bus", queueName),
slog.String("consumer", "downloadConsumer"), slog.String("consumer", "downloadConsumer"),
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
) )
if p.Progress.Status != StatusCompleted { if p.Progress.Status != StatusCompleted {
p.Start() if p.Livestream {
go p.Start() // livestreams have higher priorty and will ignore the queue
} else {
p.Start()
}
} }
m.logger.Info("started process", slog.Info("started process",
slog.String("bus", queueName), slog.String("bus", queueName),
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
) )
@@ -84,18 +85,17 @@ func (m *MessageQueue) metadataSubscriber() {
sem := semaphore.NewWeighted(1) sem := semaphore.NewWeighted(1)
m.eventBus.SubscribeAsync(queueName, func(p *Process) { m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context sem.Acquire(context.Background(), 1)
sem.Acquire(context.TODO(), 1)
defer sem.Release(1) defer sem.Release(1)
m.logger.Info("received process from event bus", slog.Info("received process from event bus",
slog.String("bus", queueName), slog.String("bus", queueName),
slog.String("consumer", "metadataConsumer"), slog.String("consumer", "metadataConsumer"),
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
) )
if p.Progress.Status == StatusCompleted { if p.Progress.Status == StatusCompleted {
m.logger.Warn("proccess has an illegal state", slog.Warn("proccess has an illegal state",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.Int("status", p.Progress.Status), slog.Int("status", p.Progress.Status),
) )
@@ -103,7 +103,7 @@ func (m *MessageQueue) metadataSubscriber() {
} }
if err := p.SetMetadata(); err != nil { if err := p.SetMetadata(); err != nil {
m.logger.Error("failed to retrieve metadata", slog.Error("failed to retrieve metadata",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("err", err.Error()), slog.String("err", err.Error()),
) )

View File

@@ -19,7 +19,7 @@ type metadata struct {
Type string `json:"_type"` Type string `json:"_type"`
} }
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger *slog.Logger) error { func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
var ( var (
downloader = config.Instance().DownloaderPath downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J") cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J")
@@ -36,7 +36,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
return err return err
} }
logger.Info("decoding playlist metadata", slog.String("url", req.URL)) slog.Info("decoding playlist metadata", slog.String("url", req.URL))
if err := json.NewDecoder(stdout).Decode(&m); err != nil { if err := json.NewDecoder(stdout).Decode(&m); err != nil {
return err return err
@@ -46,7 +46,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
return err return err
} }
logger.Info("decoded playlist metadata", slog.String("url", req.URL)) slog.Info("decoded playlist metadata", slog.String("url", req.URL))
if m.Type == "" { if m.Type == "" {
return errors.New("probably not a valid URL") return errors.New("probably not a valid URL")
@@ -57,7 +57,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
return a.URL == b.URL return a.URL == b.URL
}) })
logger.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries))) slog.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries)))
for i, meta := range entries { for i, meta := range entries {
// detect playlist title from metadata since each playlist entry will be // detect playlist title from metadata since each playlist entry will be
@@ -69,7 +69,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
1, 1,
) )
//TODO: it's idiotic but it works: virtually delay the creation time //XXX: it's idiotic but it works: virtually delay the creation time
meta.CreatedAt = time.Now().Add(time.Millisecond * time.Duration(i*10)) meta.CreatedAt = time.Now().Add(time.Millisecond * time.Duration(i*10))
proc := &Process{ proc := &Process{
@@ -78,7 +78,6 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
Output: DownloadOutput{Filename: req.Rename}, Output: DownloadOutput{Filename: req.Rename},
Info: meta, Info: meta,
Params: req.Params, Params: req.Params,
Logger: logger,
} }
proc.Info.URL = meta.URL proc.Info.URL = meta.URL
@@ -93,12 +92,11 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
proc := &Process{ proc := &Process{
Url: req.URL, Url: req.URL,
Params: req.Params, Params: req.Params,
Logger: logger,
} }
db.Set(proc) db.Set(proc)
mq.Publish(proc) mq.Publish(proc)
logger.Info("sending new process to message queue", slog.String("url", proc.Url)) slog.Info("sending new process to message queue", slog.String("url", proc.Url))
return cmd.Wait() return cmd.Wait()
} }

View File

@@ -36,18 +36,19 @@ const (
StatusDownloading StatusDownloading
StatusCompleted StatusCompleted
StatusErrored StatusErrored
StatusLivestream
) )
// Process descriptor // Process descriptor
type Process struct { type Process struct {
Id string Id string
Url string Url string
Params []string Livestream bool
Info DownloadInfo Params []string
Progress DownloadProgress Info DownloadInfo
Output DownloadOutput Progress DownloadProgress
proc *os.Process Output DownloadOutput
Logger *slog.Logger proc *os.Process
} }
// Starts spawns/forks a new yt-dlp process and parse its stdout. // Starts spawns/forks a new yt-dlp process and parse its stdout.
@@ -108,7 +109,7 @@ func (p *Process) Start() {
r, err := cmd.StdoutPipe() r, err := cmd.StdoutPipe()
if err != nil { if err != nil {
p.Logger.Error( slog.Error(
"failed to connect to stdout", "failed to connect to stdout",
slog.String("err", err.Error()), slog.String("err", err.Error()),
) )
@@ -116,7 +117,7 @@ func (p *Process) Start() {
} }
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
p.Logger.Error( slog.Error(
"failed to start yt-dlp process", "failed to start yt-dlp process",
slog.String("err", err.Error()), slog.String("err", err.Error()),
) )
@@ -167,7 +168,11 @@ func (p *Process) Start() {
ETA: progress.Eta, ETA: progress.Eta,
} }
p.Logger.Info("progress", if p.Livestream {
p.Progress.Status = StatusLivestream
}
slog.Info("progress",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("percentage", progress.Percentage), slog.String("percentage", progress.Percentage),
@@ -190,7 +195,7 @@ func (p *Process) Complete() {
ETA: 0, ETA: 0,
} }
p.Logger.Info("finished", slog.Info("finished",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
) )
@@ -227,7 +232,7 @@ func (p *Process) GetFormats() (DownloadFormats, error) {
stdout, err := cmd.Output() stdout, err := cmd.Output()
if err != nil { if err != nil {
p.Logger.Error("failed to retrieve metadata", slog.String("err", err.Error())) slog.Error("failed to retrieve metadata", slog.String("err", err.Error()))
return DownloadFormats{}, err return DownloadFormats{}, err
} }
@@ -247,7 +252,7 @@ func (p *Process) GetFormats() (DownloadFormats, error) {
p.Url, p.Url,
) )
p.Logger.Info( slog.Info(
"retrieving metadata", "retrieving metadata",
slog.String("caller", "getFormats"), slog.String("caller", "getFormats"),
slog.String("url", p.Url), slog.String("url", p.Url),
@@ -307,7 +312,7 @@ func (p *Process) SetMetadata() error {
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
p.Logger.Error("failed to connect to stdout", slog.Error("failed to connect to stdout",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("err", err.Error()), slog.String("err", err.Error()),
@@ -317,7 +322,7 @@ func (p *Process) SetMetadata() error {
stderr, err := cmd.StderrPipe() stderr, err := cmd.StderrPipe()
if err != nil { if err != nil {
p.Logger.Error("failed to connect to stderr", slog.Error("failed to connect to stderr",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("err", err.Error()), slog.String("err", err.Error()),
@@ -340,7 +345,7 @@ func (p *Process) SetMetadata() error {
io.Copy(&bufferedStderr, stderr) io.Copy(&bufferedStderr, stderr)
}() }()
p.Logger.Info("retrieving metadata", slog.Info("retrieving metadata",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
) )

View File

@@ -18,6 +18,8 @@ type OAuth2SuccessResponse struct {
IDTokenClaims *json.RawMessage IDTokenClaims *json.RawMessage
} }
// var cookieMaxAge = int(time.Hour * 24 * 30) XXX: overflows on 32 bit architectures.
func Login(w http.ResponseWriter, r *http.Request) { func Login(w http.ResponseWriter, r *http.Request) {
state := uuid.NewString() state := uuid.NewString()
@@ -32,7 +34,8 @@ func Login(w http.ResponseWriter, r *http.Request) {
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
Secure: r.TLS != nil, Secure: r.TLS != nil,
MaxAge: int(time.Hour * 24 * 30), // MaxAge: cookieMaxAge,
Expires: time.Now().Add(time.Hour * 24 * 30), // XXX: change to MaxAge
}) })
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
@@ -41,7 +44,8 @@ func Login(w http.ResponseWriter, r *http.Request) {
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
Secure: r.TLS != nil, Secure: r.TLS != nil,
MaxAge: int(time.Hour * 24 * 30), // MaxAge: cookieMaxAge,
Expires: time.Now().Add(time.Hour * 24 * 30), // XXX: change to MaxAge
}) })
http.Redirect(w, r, oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound) http.Redirect(w, r, oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
@@ -108,7 +112,7 @@ func SingIn(w http.ResponseWriter, r *http.Request) {
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
Secure: r.TLS != nil, Secure: r.TLS != nil,
MaxAge: int(time.Hour * 24 * 30), // MaxAge: int(time.Hour * 24 * 30), XXX: overflows on 32 bit architectures.
}) })
}) })
if err != nil { if err != nil {
@@ -116,11 +120,6 @@ func SingIn(w http.ResponseWriter, r *http.Request) {
return return
} }
// if err := json.NewEncoder(w).Encode(res); err != nil {
// http.Error(w, err.Error(), http.StatusInternalServerError)
// return
// }
w.Write([]byte("Login succesfully, you may now close this window and refresh yt-dlp-webui.")) w.Write([]byte("Login succesfully, you may now close this window and refresh yt-dlp-webui."))
} }
@@ -141,7 +140,7 @@ func Refresh(w http.ResponseWriter, r *http.Request) {
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
Secure: r.TLS != nil, Secure: r.TLS != nil,
MaxAge: int(time.Hour * 24 * 30), // MaxAge: int(time.Hour * 24 * 30), XXX: overflows on 32 bit architectures.
}) })
token.AccessToken = "*redacted*" token.AccessToken = "*redacted*"

View File

@@ -2,14 +2,12 @@ package rest
import ( import (
"database/sql" "database/sql"
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
type ContainerArgs struct { type ContainerArgs struct {
DB *sql.DB DB *sql.DB
MDB *internal.MemoryDB MDB *internal.MemoryDB
MQ *internal.MessageQueue MQ *internal.MessageQueue
Logger *slog.Logger
} }

View File

@@ -15,10 +15,9 @@ var (
func ProvideService(args *ContainerArgs) *Service { func ProvideService(args *ContainerArgs) *Service {
serviceOnce.Do(func() { serviceOnce.Do(func() {
service = &Service{ service = &Service{
mdb: args.MDB, mdb: args.MDB,
db: args.DB, db: args.DB,
mq: args.MQ, mq: args.MQ,
logger: args.Logger,
} }
}) })
return service return service

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"time" "time"
@@ -15,10 +14,9 @@ import (
) )
type Service struct { type Service struct {
mdb *internal.MemoryDB mdb *internal.MemoryDB
db *sql.DB db *sql.DB
mq *internal.MessageQueue mq *internal.MessageQueue
logger *slog.Logger
} }
func (s *Service) Exec(req internal.DownloadRequest) (string, error) { func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
@@ -29,7 +27,6 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
Path: req.Path, Path: req.Path,
Filename: req.Rename, Filename: req.Rename,
}, },
Logger: s.logger,
} }
id := s.mdb.Set(p) id := s.mdb.Set(p)

View File

@@ -1,25 +1,20 @@
package rpc package rpc
import ( import (
"log/slog"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid" "github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
) )
// Dependency injection container. // Dependency injection container.
func Container( func Container(db *internal.MemoryDB, mq *internal.MessageQueue, lm *livestream.Monitor) *Service {
db *internal.MemoryDB,
mq *internal.MessageQueue,
logger *slog.Logger,
) *Service {
return &Service{ return &Service{
db: db, db: db,
mq: mq, mq: mq,
logger: logger, lm: lm,
} }
} }

View File

@@ -5,14 +5,15 @@ import (
"log/slog" "log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys" "github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater" "github.com/marcopeocchi/yt-dlp-web-ui/server/updater"
) )
type Service struct { type Service struct {
db *internal.MemoryDB db *internal.MemoryDB
mq *internal.MessageQueue mq *internal.MessageQueue
logger *slog.Logger lm *livestream.Monitor
} }
type Running []internal.ProcessResponse type Running []internal.ProcessResponse
@@ -36,7 +37,6 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
Path: args.Path, Path: args.Path,
Filename: args.Rename, Filename: args.Rename,
}, },
Logger: s.logger,
} }
s.db.Set(p) s.db.Set(p)
@@ -49,7 +49,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
// Exec spawns a Process. // Exec spawns a Process.
// The result of the execution is the newly spawned process Id. // The result of the execution is the newly spawned process Id.
func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error { func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error {
err := internal.PlaylistDetect(args, s.mq, s.db, s.logger) err := internal.PlaylistDetect(args, s.mq, s.db)
if err != nil { if err != nil {
return err return err
} }
@@ -58,6 +58,38 @@ func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) er
return nil return nil
} }
// TODO: docs
func (s *Service) ExecLivestream(args internal.DownloadRequest, result *string) error {
s.lm.Add(args.URL)
*result = args.URL
return nil
}
// TODO: docs
func (s *Service) ProgressLivestream(args NoArgs, result *livestream.LiveStreamStatus) error {
*result = s.lm.Status()
return nil
}
// TODO: docs
func (s *Service) KillLivestream(args string, result *struct{}) error {
slog.Info("killing livestream", slog.String("url", args))
err := s.lm.Remove(args)
if err != nil {
slog.Error("failed killing livestream", slog.String("url", args), slog.Any("err", err))
return err
}
return nil
}
// TODO: docs
func (s *Service) KillAllLivestream(args NoArgs, result *struct{}) error {
return s.lm.RemoveAll()
}
// Progess retrieves the Progress of a specific Process given its Id // Progess retrieves the Progress of a specific Process given its Id
func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error { func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error {
proc, err := s.db.Get(args.Id) proc, err := s.db.Get(args.Id)
@@ -73,7 +105,7 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error { func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
var ( var (
err error err error
p = internal.Process{Url: args.URL, Logger: s.logger} p = internal.Process{Url: args.URL}
) )
*meta, err = p.GetFormats() *meta, err = p.GetFormats()
return err return err
@@ -93,7 +125,7 @@ func (s *Service) Running(args NoArgs, running *Running) error {
// Kill kills a process given its id and remove it from the memoryDB // Kill kills a process given its id and remove it from the memoryDB
func (s *Service) Kill(args string, killed *string) error { func (s *Service) Kill(args string, killed *string) error {
s.logger.Info("Trying killing process with id", slog.String("id", args)) slog.Info("Trying killing process with id", slog.String("id", args))
proc, err := s.db.Get(args) proc, err := s.db.Get(args)
if err != nil { if err != nil {
@@ -105,12 +137,12 @@ func (s *Service) Kill(args string, killed *string) error {
} }
if err := proc.Kill(); err != nil { if err := proc.Kill(); err != nil {
s.logger.Info("failed killing process", slog.String("id", proc.Id), slog.Any("err", err)) slog.Info("failed killing process", slog.String("id", proc.Id), slog.Any("err", err))
return err return err
} }
s.db.Delete(proc.Id) s.db.Delete(proc.Id)
s.logger.Info("succesfully killed process", slog.String("id", proc.Id)) slog.Info("succesfully killed process", slog.String("id", proc.Id))
return nil return nil
} }
@@ -118,7 +150,7 @@ func (s *Service) Kill(args string, killed *string) error {
// KillAll kills all process unconditionally and removes them from // KillAll kills all process unconditionally and removes them from
// the memory db // the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error { func (s *Service) KillAll(args NoArgs, killed *string) error {
s.logger.Info("Killing all spawned processes") slog.Info("Killing all spawned processes")
var ( var (
keys = s.db.Keys() keys = s.db.Keys()
@@ -140,7 +172,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
} }
if err := removeFunc(proc); err != nil { if err := removeFunc(proc); err != nil {
s.logger.Info( slog.Info(
"failed killing process", "failed killing process",
slog.String("id", proc.Id), slog.String("id", proc.Id),
slog.Any("err", err), slog.Any("err", err),
@@ -148,7 +180,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
continue continue
} }
s.logger.Info("succesfully killed process", slog.String("id", proc.Id)) slog.Info("succesfully killed process", slog.String("id", proc.Id))
} }
return nil return nil
@@ -156,7 +188,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
// Remove a process from the db rendering it unusable if active // Remove a process from the db rendering it unusable if active
func (s *Service) Clear(args string, killed *string) error { func (s *Service) Clear(args string, killed *string) error {
s.logger.Info("Clearing process with id", slog.String("id", args)) slog.Info("Clearing process with id", slog.String("id", args))
s.db.Delete(args) s.db.Delete(args)
return nil return nil
} }
@@ -190,16 +222,16 @@ func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
// Updates the yt-dlp binary using its builtin function // Updates the yt-dlp binary using its builtin function
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error { func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
s.logger.Info("Updating yt-dlp executable to the latest release") slog.Info("Updating yt-dlp executable to the latest release")
if err := updater.UpdateExecutable(); err != nil { if err := updater.UpdateExecutable(); err != nil {
s.logger.Error("Failed updating yt-dlp") slog.Error("Failed updating yt-dlp")
*updated = false *updated = false
return err return err
} }
*updated = true *updated = true
s.logger.Info("Succesfully updated yt-dlp") slog.Info("Succesfully updated yt-dlp")
return nil return nil
} }

View File

@@ -1,3 +1,4 @@
// a stupid package name...
package server package server
import ( import (
@@ -22,6 +23,7 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server/dbutil" "github.com/marcopeocchi/yt-dlp-web-ui/server/dbutil"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers" "github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging" "github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid" "github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
@@ -44,7 +46,6 @@ type RunConfig struct {
type serverConfig struct { type serverConfig struct {
frontend fs.FS frontend fs.FS
swagger fs.FS swagger fs.FS
logger *slog.Logger
host string host string
port int port int
mdb *internal.MemoryDB mdb *internal.MemoryDB
@@ -57,9 +58,10 @@ func RunBlocking(cfg *RunConfig) {
logWriters := []io.Writer{ logWriters := []io.Writer{
os.Stdout, os.Stdout,
logging.NewObservableLogger(), logging.NewObservableLogger(), // for web-ui
} }
// file based logging
if cfg.FileLogging { if cfg.FileLogging {
logger, err := logging.NewRotableLogger(cfg.LogFile) logger, err := logging.NewRotableLogger(cfg.LogFile)
if err != nil { if err != nil {
@@ -76,31 +78,33 @@ func RunBlocking(cfg *RunConfig) {
logWriters = append(logWriters, logger) logWriters = append(logWriters, logger)
} }
logger := slog.New( logger := slog.New(slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{
slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}), Level: slog.LevelInfo, // TODO: detect when launched in debug mode -> slog.LevelDebug
) }))
// make the new logger the default one with all the new writers
slog.SetDefault(logger)
db, err := sql.Open("sqlite", cfg.DBPath) db, err := sql.Open("sqlite", cfg.DBPath)
if err != nil { if err != nil {
logger.Error("failed to open database", slog.String("err", err.Error())) slog.Error("failed to open database", slog.String("err", err.Error()))
} }
if err := dbutil.Migrate(context.Background(), db); err != nil { if err := dbutil.Migrate(context.Background(), db); err != nil {
logger.Error("failed to init database", slog.String("err", err.Error())) slog.Error("failed to init database", slog.String("err", err.Error()))
} }
mq, err := internal.NewMessageQueue(logger) mq, err := internal.NewMessageQueue()
if err != nil { if err != nil {
panic(err) panic(err)
} }
mq.SetupConsumers() mq.SetupConsumers()
go mdb.Restore(mq, logger) go mdb.Restore(mq)
srv := newServer(serverConfig{ srv := newServer(serverConfig{
frontend: cfg.App, frontend: cfg.App,
swagger: cfg.Swagger, swagger: cfg.Swagger,
logger: logger,
host: cfg.Host, host: cfg.Host,
port: cfg.Port, port: cfg.Port,
mdb: &mdb, mdb: &mdb,
@@ -109,13 +113,14 @@ func RunBlocking(cfg *RunConfig) {
}) })
go gracefulShutdown(srv, &mdb) go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &mdb, logger) go autoPersist(time.Minute*5, &mdb)
var ( var (
network = "tcp" network = "tcp"
address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
) )
// support unix sockets
if strings.HasPrefix(cfg.Host, "/") { if strings.HasPrefix(cfg.Host, "/") {
network = "unix" network = "unix"
address = cfg.Host address = cfg.Host
@@ -123,19 +128,30 @@ func RunBlocking(cfg *RunConfig) {
listener, err := net.Listen(network, address) listener, err := net.Listen(network, address)
if err != nil { if err != nil {
logger.Error("failed to listen", slog.String("err", err.Error())) slog.Error("failed to listen", slog.String("err", err.Error()))
return return
} }
logger.Info("yt-dlp-webui started", slog.String("address", address)) slog.Info("yt-dlp-webui started", slog.String("address", address))
if err := srv.Serve(listener); err != nil { if err := srv.Serve(listener); err != nil {
logger.Warn("http server stopped", slog.String("err", err.Error())) slog.Warn("http server stopped", slog.String("err", err.Error()))
} }
} }
func newServer(c serverConfig) *http.Server { func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.mdb, c.mq, c.logger) lm := livestream.NewMonitor(c.mq, c.mdb)
go lm.Schedule()
go lm.Restore()
go func() {
for {
lm.Persist()
time.Sleep(time.Minute * 5)
}
}()
service := ytdlpRPC.Container(c.mdb, c.mq, lm)
rpc.Register(service) rpc.Register(service)
r := chi.NewRouter() r := chi.NewRouter()
@@ -196,10 +212,9 @@ func newServer(c serverConfig) *http.Server {
// REST API handlers // REST API handlers
r.Route("/api/v1", rest.ApplyRouter(&rest.ContainerArgs{ r.Route("/api/v1", rest.ApplyRouter(&rest.ContainerArgs{
DB: c.db, DB: c.db,
MDB: c.mdb, MDB: c.mdb,
MQ: c.mq, MQ: c.mq,
Logger: c.logger,
})) }))
// Logging // Logging
@@ -227,15 +242,15 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
}() }()
} }
func autoPersist(d time.Duration, db *internal.MemoryDB, logger *slog.Logger) { func autoPersist(d time.Duration, db *internal.MemoryDB) {
for { for {
if err := db.Persist(); err != nil { if err := db.Persist(); err != nil {
logger.Info( slog.Warn(
"failed to persisted session", "failed to persisted session",
slog.String("err", err.Error()), slog.String("err", err.Error()),
) )
} }
logger.Info("sucessfully persisted session") slog.Debug("sucessfully persisted session")
time.Sleep(d) time.Sleep(d)
} }
} }