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/.yarn/install-state.gz
.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
A not so terrible web ui for yt-dlp.

View File

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

View File

@@ -2,6 +2,7 @@ import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard'
import LiveTvIcon from '@mui/icons-material/LiveTv'
import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings'
import TerminalIcon from '@mui/icons-material/Terminal'
@@ -121,6 +122,19 @@ export default function Layout() {
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</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={
{
textDecoration: 'none',

View File

@@ -24,7 +24,7 @@ languages:
overridesAnchor: Overrides
pathOverrideOption: Enable output path 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
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
customArgsInput: Custom yt-dlp arguments
@@ -54,6 +54,16 @@ languages:
rpcPollingTimeTitle: RPC polling time
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.
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:
urlInput: Video URL
statusTitle: Status
@@ -78,7 +88,7 @@ languages:
overridesAnchor: Überschreibungen
pathOverrideOption: Ausgabe-Pfad Ü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
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
customArgsInput: Benutzerdefinierte yt-dlp Argumente
@@ -104,6 +114,16 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut
@@ -156,6 +176,16 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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:
urlInput: URL Video (uno per linea)
statusTitle: Stato
@@ -179,7 +209,7 @@ languages:
overridesAnchor: Sovrascritture
pathOverrideOption: Abilita sovrascrittura percorso 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
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments
@@ -205,6 +235,16 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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:
urlInput: 视频 URL
statusTitle: 状态
@@ -255,6 +295,16 @@ languages:
logsTitle: '日志'
awaitingLogs: '正在等待日志…'
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:
urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado
@@ -303,6 +353,16 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус
@@ -334,23 +394,33 @@ languages:
splashText: Нет активных загрузок
archiveTitle: Архив
clipboardAction: URL скопирован в буфер обмена
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно
servedFromReverseProxyCheckbox: Находится за обратным прокси
newDownloadButton: Новая загрузка
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
archiveButtonLabel: Архив
settingsButtonLabel: Настройки
rpcAuthenticationLabel: RPC-аутентификация
themeTogglerLabel: Переключить тему
loadingLabel: Загрузка...
appTitle: Название приложения
savedTemplates: Сохраненные шаблоны
templatesEditor: Редактор шаблонов
templatesEditorNameLabel: Имя шаблона
templatesEditorContentLabel: Содержание шаблона
logsTitle: 'Логи'
awaitingLogs: 'Ожидание логов...'
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:
urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태
@@ -374,7 +444,7 @@ languages:
overridesAnchor: Overrides
pathOverrideOption: Enable output path 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
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments
@@ -399,6 +469,16 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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:
urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態
@@ -448,6 +528,16 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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:
urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat
@@ -496,6 +586,16 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус
@@ -544,6 +644,16 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status
@@ -591,4 +701,78 @@ languages:
templatesEditorContentLabel: Template content
logsTitle: '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'
}}>
<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 />
</div>
<div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}>

View File

@@ -1,10 +1,10 @@
import { Backdrop, CircularProgress } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
const LoadingBackdrop: React.FC = () => {
const isLoading = useRecoilValue(loadingAtom)
type Props = {
isLoading: boolean
}
const LoadingBackdrop: React.FC<Props> = ({ isLoading }) => {
return (
<Backdrop
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 type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
import type { DLMetadata, LiveStreamProgress, RPCRequest, RPCResponse, RPCResult } from '../types'
import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
@@ -160,9 +160,32 @@ export class RPCClient {
})
}
public updateExecutable() {
public execLivestream(url: string) {
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: []
})
}

View File

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

View File

@@ -9,6 +9,10 @@ export type RPCMethods =
| "Service.ExecPlaylist"
| "Service.DirectoryTree"
| "Service.UpdateExecutable"
| "Service.ExecLivestream"
| "Service.ProgressLivestream"
| "Service.KillLivestream"
| "Service.KillAllLivestream"
export type RPCRequest = {
method: RPCMethods
@@ -96,4 +100,17 @@ export type CustomTemplate = {
id: string
name: 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 {
Container
} from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
import Downloads from '../components/Downloads'
import HomeActions from '../components/HomeActions'
import LoadingBackdrop from '../components/LoadingBackdrop'
import Splash from '../components/Splash'
export default function Home() {
const isLoading = useRecoilValue(loadingAtom)
return (
<Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}>
<LoadingBackdrop />
<LoadingBackdrop isLoading={isLoading} />
<Splash />
<Downloads />
<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
// TODO: further path / file validations
if strings.Contains(filepath.Dir(filename), root) {
http.ServeFile(w, r, filename)
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 (
"encoding/gob"
"errors"
"log/slog"
"os"
"path/filepath"
"sync"
@@ -92,7 +91,7 @@ func (m *MemoryDB) Persist() error {
}
// 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")
if err != nil {
return
@@ -112,7 +111,6 @@ func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) {
Progress: proc.Progress,
Output: proc.Output,
Params: proc.Params,
Logger: logger,
}
m.table.Store(proc.Id, restored)

View File

@@ -15,14 +15,13 @@ const queueName = "process:pending"
type MessageQueue struct {
concurrency int
eventBus evbus.Bus
logger *slog.Logger
}
// Creates a new message queue.
// By default it will be created with a size equals to nthe number of logical
// CPU cores -1.
// 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
if qs <= 0 {
@@ -32,7 +31,6 @@ func NewMessageQueue(l *slog.Logger) (*MessageQueue, error) {
return &MessageQueue{
concurrency: qs,
eventBus: evbus.New(),
logger: l,
}, nil
}
@@ -55,21 +53,24 @@ func (m *MessageQueue) downloadConsumer() {
sem := semaphore.NewWeighted(int64(m.concurrency))
m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.Background(), 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("consumer", "downloadConsumer"),
slog.String("id", p.getShortId()),
)
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("id", p.getShortId()),
)
@@ -84,18 +85,17 @@ func (m *MessageQueue) metadataSubscriber() {
sem := semaphore.NewWeighted(1)
m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.TODO(), 1)
sem.Acquire(context.Background(), 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("consumer", "metadataConsumer"),
slog.String("id", p.getShortId()),
)
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.Int("status", p.Progress.Status),
)
@@ -103,7 +103,7 @@ func (m *MessageQueue) metadataSubscriber() {
}
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("err", err.Error()),
)

View File

@@ -19,7 +19,7 @@ type metadata struct {
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 (
downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J")
@@ -36,7 +36,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
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 {
return err
@@ -46,7 +46,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
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 == "" {
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
})
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 {
// 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,
)
//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))
proc := &Process{
@@ -78,7 +78,6 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
Output: DownloadOutput{Filename: req.Rename},
Info: meta,
Params: req.Params,
Logger: logger,
}
proc.Info.URL = meta.URL
@@ -93,12 +92,11 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
proc := &Process{
Url: req.URL,
Params: req.Params,
Logger: logger,
}
db.Set(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()
}

View File

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

View File

@@ -18,6 +18,8 @@ type OAuth2SuccessResponse struct {
IDTokenClaims *json.RawMessage
}
// var cookieMaxAge = int(time.Hour * 24 * 30) XXX: overflows on 32 bit architectures.
func Login(w http.ResponseWriter, r *http.Request) {
state := uuid.NewString()
@@ -32,7 +34,8 @@ func Login(w http.ResponseWriter, r *http.Request) {
HttpOnly: true,
Path: "/",
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{
@@ -41,7 +44,8 @@ func Login(w http.ResponseWriter, r *http.Request) {
HttpOnly: true,
Path: "/",
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)
@@ -108,7 +112,7 @@ func SingIn(w http.ResponseWriter, r *http.Request) {
HttpOnly: true,
Path: "/",
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 {
@@ -116,11 +120,6 @@ func SingIn(w http.ResponseWriter, r *http.Request) {
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."))
}
@@ -141,7 +140,7 @@ func Refresh(w http.ResponseWriter, r *http.Request) {
HttpOnly: true,
Path: "/",
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*"

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"errors"
"log/slog"
"os"
"os/exec"
"time"
@@ -15,10 +14,9 @@ import (
)
type Service struct {
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
logger *slog.Logger
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
}
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,
Filename: req.Rename,
},
Logger: s.logger,
}
id := s.mdb.Set(p)

View File

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

View File

@@ -5,14 +5,15 @@ import (
"log/slog"
"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/updater"
)
type Service struct {
db *internal.MemoryDB
mq *internal.MessageQueue
logger *slog.Logger
db *internal.MemoryDB
mq *internal.MessageQueue
lm *livestream.Monitor
}
type Running []internal.ProcessResponse
@@ -36,7 +37,6 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
Path: args.Path,
Filename: args.Rename,
},
Logger: s.logger,
}
s.db.Set(p)
@@ -49,7 +49,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
// Exec spawns a Process.
// The result of the execution is the newly spawned process Id.
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 {
return err
}
@@ -58,6 +58,38 @@ func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) er
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
func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error {
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 {
var (
err error
p = internal.Process{Url: args.URL, Logger: s.logger}
p = internal.Process{Url: args.URL}
)
*meta, err = p.GetFormats()
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
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)
if err != nil {
@@ -105,12 +137,12 @@ func (s *Service) Kill(args string, killed *string) error {
}
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
}
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
}
@@ -118,7 +150,7 @@ func (s *Service) Kill(args string, killed *string) error {
// KillAll kills all process unconditionally and removes them from
// the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error {
s.logger.Info("Killing all spawned processes")
slog.Info("Killing all spawned processes")
var (
keys = s.db.Keys()
@@ -140,7 +172,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
}
if err := removeFunc(proc); err != nil {
s.logger.Info(
slog.Info(
"failed killing process",
slog.String("id", proc.Id),
slog.Any("err", err),
@@ -148,7 +180,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
continue
}
s.logger.Info("succesfully killed process", slog.String("id", proc.Id))
slog.Info("succesfully killed process", slog.String("id", proc.Id))
}
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
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)
return nil
}
@@ -190,16 +222,16 @@ func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
// Updates the yt-dlp binary using its builtin function
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 {
s.logger.Error("Failed updating yt-dlp")
slog.Error("Failed updating yt-dlp")
*updated = false
return err
}
*updated = true
s.logger.Info("Succesfully updated yt-dlp")
slog.Info("Succesfully updated yt-dlp")
return nil
}

View File

@@ -1,3 +1,4 @@
// a stupid package name...
package server
import (
@@ -22,6 +23,7 @@ import (
"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/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
@@ -44,7 +46,6 @@ type RunConfig struct {
type serverConfig struct {
frontend fs.FS
swagger fs.FS
logger *slog.Logger
host string
port int
mdb *internal.MemoryDB
@@ -57,9 +58,10 @@ func RunBlocking(cfg *RunConfig) {
logWriters := []io.Writer{
os.Stdout,
logging.NewObservableLogger(),
logging.NewObservableLogger(), // for web-ui
}
// file based logging
if cfg.FileLogging {
logger, err := logging.NewRotableLogger(cfg.LogFile)
if err != nil {
@@ -76,31 +78,33 @@ func RunBlocking(cfg *RunConfig) {
logWriters = append(logWriters, logger)
}
logger := slog.New(
slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}),
)
logger := slog.New(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)
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 {
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 {
panic(err)
}
mq.SetupConsumers()
go mdb.Restore(mq, logger)
go mdb.Restore(mq)
srv := newServer(serverConfig{
frontend: cfg.App,
swagger: cfg.Swagger,
logger: logger,
host: cfg.Host,
port: cfg.Port,
mdb: &mdb,
@@ -109,13 +113,14 @@ func RunBlocking(cfg *RunConfig) {
})
go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &mdb, logger)
go autoPersist(time.Minute*5, &mdb)
var (
network = "tcp"
address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
)
// support unix sockets
if strings.HasPrefix(cfg.Host, "/") {
network = "unix"
address = cfg.Host
@@ -123,19 +128,30 @@ func RunBlocking(cfg *RunConfig) {
listener, err := net.Listen(network, address)
if err != nil {
logger.Error("failed to listen", slog.String("err", err.Error()))
slog.Error("failed to listen", slog.String("err", err.Error()))
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 {
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 {
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)
r := chi.NewRouter()
@@ -196,10 +212,9 @@ func newServer(c serverConfig) *http.Server {
// REST API handlers
r.Route("/api/v1", rest.ApplyRouter(&rest.ContainerArgs{
DB: c.db,
MDB: c.mdb,
MQ: c.mq,
Logger: c.logger,
DB: c.db,
MDB: c.mdb,
MQ: c.mq,
}))
// 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 {
if err := db.Persist(); err != nil {
logger.Info(
slog.Warn(
"failed to persisted session",
slog.String("err", err.Error()),
)
}
logger.Info("sucessfully persisted session")
slog.Debug("sucessfully persisted session")
time.Sleep(d)
}
}