Compare commits
18 Commits
v3.1.2
...
v3.2.0-fix
| Author | SHA1 | Date | |
|---|---|---|---|
| 54771b2d78 | |||
| fceb36c723 | |||
| c4075fb640 | |||
|
|
aa8191b0cd | ||
| a6626973ac | |||
| 79f1473c6a | |||
|
|
b76f2b72be | ||
| 8f2d9eaf6e | |||
|
|
c51f320a6f | ||
| 8b26bf513f | |||
| 25210ccc22 | |||
| 3205711bb1 | |||
| 92e3fd994e | |||
| 01e9da61eb | |||
|
|
fd5e62e23b | ||
| a64798644a | |||
|
|
b7511eb064 | ||
|
|
16f8f74f9b |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.tsx linguist-detectable=false
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -592,3 +702,77 @@ 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!
|
||||||
|
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!
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
125
frontend/src/components/livestream/LivestreamDialog.tsx
Normal file
125
frontend/src/components/livestream/LivestreamDialog.tsx
Normal 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
|
||||||
34
frontend/src/components/livestream/LivestreamSpeedDial.tsx
Normal file
34
frontend/src/components/livestream/LivestreamSpeedDial.tsx
Normal 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
|
||||||
38
frontend/src/components/livestream/NoLivestreams.tsx
Normal file
38
frontend/src/components/livestream/NoLivestreams.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 >
|
||||||
|
)
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -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
|
||||||
@@ -97,3 +101,16 @@ export type CustomTemplate = {
|
|||||||
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
|
||||||
|
}>
|
||||||
@@ -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 />
|
||||||
|
|||||||
134
frontend/src/views/Livestream.tsx
Normal file
134
frontend/src/views/Livestream.tsx
Normal 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
|
||||||
3187
frontend/yarn.lock
3187
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
211
server/internal/livestream/livestream.go
Normal file
211
server/internal/livestream/livestream.go
Normal 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
|
||||||
|
}
|
||||||
36
server/internal/livestream/livestream_test.go
Normal file
36
server/internal/livestream/livestream_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
server/internal/livestream/monitor.go
Normal file
117
server/internal/livestream/monitor.go
Normal 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
|
||||||
|
}
|
||||||
1
server/internal/livestream/monitor_test.go
Normal file
1
server/internal/livestream/monitor_test.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package livestream
|
||||||
11
server/internal/livestream/status.go
Normal file
11
server/internal/livestream/status.go
Normal 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
|
||||||
|
}
|
||||||
16
server/internal/livestream/utils.go
Normal file
16
server/internal/livestream/utils.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if p.Livestream {
|
||||||
|
go p.Start() // livestreams have higher priorty and will ignore the queue
|
||||||
|
} else {
|
||||||
p.Start()
|
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()),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
Livestream bool
|
||||||
Params []string
|
Params []string
|
||||||
Info DownloadInfo
|
Info DownloadInfo
|
||||||
Progress DownloadProgress
|
Progress DownloadProgress
|
||||||
Output DownloadOutput
|
Output DownloadOutput
|
||||||
proc *os.Process
|
proc *os.Process
|
||||||
Logger *slog.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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*"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -11,5 +10,4 @@ type ContainerArgs struct {
|
|||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
MDB *internal.MemoryDB
|
MDB *internal.MemoryDB
|
||||||
MQ *internal.MessageQueue
|
MQ *internal.MessageQueue
|
||||||
Logger *slog.Logger
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ func ProvideService(args *ContainerArgs) *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
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,7 +17,6 @@ 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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -199,7 +215,6 @@ func newServer(c serverConfig) *http.Server {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user