Compare commits

..

17 Commits

Author SHA1 Message Date
4f8510bac8 fixed twitch authentication 2025-08-25 12:48:30 +02:00
4e1b4bad0a backend code 2025-08-24 15:55:44 +02:00
Marco Piovanello
14a03d6a77 Prevent RCEs with crafted inputs 2025-07-23 10:21:34 +02:00
Marco Piovanello
8a73079fad Update Dockerfile 2025-04-13 20:13:59 +02:00
f578f44cfd refactor: prevent multiple slashes 2025-03-30 10:29:13 +02:00
cbe16c5c6c refactoring: readded abort controller to httpClient.ts 2025-03-30 10:21:19 +02:00
3cebaf7f61 refactor: extra slashes prevention 2025-03-30 10:17:30 +02:00
Marco Piovanello
2d2cb1dc3a Update README.md 2025-03-30 09:54:27 +02:00
Marco Piovanello
43bcc40907 293 tiny gui improvement (#296)
* clicking on the speed dial will open download dialog

* refactor: prevent multiple slashes
2025-03-29 21:27:28 +01:00
Marco Piovanello
2af27e51be Chore dockerfile refactor (#287)
* removed yt-dlp alpine package

* use python3-alpine base image
2025-03-22 16:17:25 +01:00
Marco Piovanello
8c18242aaf removed yt-dlp alpine package (#286) 2025-03-22 15:27:48 +01:00
Marco Piovanello
66bebb2529 Update README.md 2025-03-17 11:23:29 +01:00
Marco Piovanello
e223e030ac restrict user with a whitelist (#282) 2025-03-17 11:13:20 +01:00
e4362468f7 fixed livestreams not being monitored 2025-03-15 11:08:08 +01:00
6880f60d14 Code refactoring, added clear button 2025-03-13 11:22:17 +01:00
Marco Piovanello
5d4aa7e2a3 Update README.md 2025-03-09 17:07:56 +01:00
Piotr Hajdas
2845196bc7 Add one-click deploy options for AWS, DigitalOcean, and Render in README (#268) 2025-02-20 09:47:11 +01:00
41 changed files with 652 additions and 392 deletions

View File

@@ -0,0 +1 @@
docker run -d -p 3033:3033 -v /downloads:/downloads marcobaobao/yt-dlp-webui

View File

@@ -3,6 +3,7 @@
result/ result/
result result
dist dist
.pnpm-store/
.pnpm-debug.log .pnpm-debug.log
node_modules node_modules
.env .env
@@ -20,9 +21,11 @@ cookies.txt
__debug* __debug*
ui/ ui/
.idea .idea
.idea/
frontend/.pnp.cjs 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 livestreams.dat
.git .vite/deps
archive.txt

2
.gitignore vendored
View File

@@ -29,4 +29,4 @@ frontend/.yarn/install-state.gz
livestreams.dat livestreams.dat
.vite/deps .vite/deps
archive.txt archive.txt
web_config.yml twitch-monitor.dat

View File

@@ -24,11 +24,12 @@ COPY --from=ui /usr/src/yt-dlp-webui/frontend /usr/src/yt-dlp-webui/frontend
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# dependencies ---------------------------------------------------------------- # Runtime ---------------------------------------------------------------------
FROM alpine:edge FROM python:3.13.2-alpine3.21
RUN apk update && \ RUN apk update && \
apk add ffmpeg yt-dlp ca-certificates curl wget psmisc apk add ffmpeg ca-certificates curl wget gnutls --no-cache && \
pip install "yt-dlp[default,curl-cffi,mutagen,pycryptodomex,phantomjs,secretstorage]"
VOLUME /downloads /config VOLUME /downloads /config
@@ -39,4 +40,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret ENV JWT_SECRET=secret
EXPOSE 3033 EXPOSE 3033
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ] ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]

View File

@@ -28,7 +28,7 @@ docker pull ghcr.io/marcopiovanello/yt-dlp-web-ui:latest
## Community stuff ## Community stuff
Feel free to join :) Feel free to join :)
[![Discord Banner](https://api.weblutions.com/discord/invite/3Sj9ZZHv/)](https://discord.gg/3Sj9ZZHv) [Discord](https://discord.gg/GZAX5FfGzE)
## Some screeshots ## Some screeshots
![image](https://github.com/user-attachments/assets/fc43a3fb-ecf9-449d-b5cb-5d5635020c00) ![image](https://github.com/user-attachments/assets/fc43a3fb-ecf9-449d-b5cb-5d5635020c00)
@@ -115,6 +115,16 @@ services:
restart: unless-stopped restart: unless-stopped
``` ```
### ⚡ One-Click Deploy
| Cloud Provider | Deploy Button |
|----------------|---------------|
| AWS | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=aws&language=cfn"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/aws.svg" height="38"></a> |
| DigitalOcean | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=do&language=dop"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/do.svg" height="38"></a> |
| Render | <a href="https://deploystack.io/deploy/marcopiovanello-yt-dlp-web-ui?provider=rnd&language=rnd"><img src="https://raw.githubusercontent.com/deploystackio/deploy-templates/refs/heads/main/.assets/img/rnd.svg" height="38"></a> |
<sub>Generated by <a href="https://deploystack.io/c/marcopiovanello-yt-dlp-web-ui" target="_blank">DeployStack.io</a></sub>
## [Prebuilt binaries](https://github.com/marcopiovanello/yt-dlp-web-ui/releases) installation ## [Prebuilt binaries](https://github.com/marcopiovanello/yt-dlp-web-ui/releases) installation
```sh ```sh

View File

@@ -18,12 +18,11 @@
"@mui/icons-material": "^6.2.0", "@mui/icons-material": "^6.2.0",
"@mui/material": "^6.2.0", "@mui/material": "^6.2.0",
"fp-ts": "^2.16.5", "fp-ts": "^2.16.5",
"jotai": "^2.10.3",
"jotai-cache": "^0.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"react-virtuoso": "^4.7.11", "react-virtuoso": "^4.7.11",
"jotai": "^2.10.3",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -32,9 +32,6 @@ importers:
jotai: jotai:
specifier: ^2.10.3 specifier: ^2.10.3
version: 2.10.3(@types/react@19.0.1)(react@19.0.0) version: 2.10.3(@types/react@19.0.1)(react@19.0.0)
jotai-cache:
specifier: ^0.5.0
version: 0.5.0(jotai@2.10.3(@types/react@19.0.1)(react@19.0.0))
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.0.0 version: 19.0.0
@@ -740,11 +737,6 @@ packages:
is-core-module@2.12.1: is-core-module@2.12.1:
resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==}
jotai-cache@0.5.0:
resolution: {integrity: sha512-29pUuEfSXL7Ba6lxZmiNDARc73TspWzAzCy0jCkk2uEOnFJ6kaUBZTp/AZSwnIsh1ndfUfM9/QpbLU7uJAQL0A==}
peerDependencies:
jotai: '>=2.0.0'
jotai@2.10.3: jotai@2.10.3:
resolution: {integrity: sha512-Nnf4IwrLhNfuz2JOQLI0V/AgwcpxvVy8Ec8PidIIDeRi4KCFpwTFIpHAAcU+yCgnw/oASYElq9UY0YdUUegsSA==} resolution: {integrity: sha512-Nnf4IwrLhNfuz2JOQLI0V/AgwcpxvVy8Ec8PidIIDeRi4KCFpwTFIpHAAcU+yCgnw/oASYElq9UY0YdUUegsSA==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -1520,10 +1512,6 @@ snapshots:
dependencies: dependencies:
has: 1.0.3 has: 1.0.3
jotai-cache@0.5.0(jotai@2.10.3(@types/react@19.0.1)(react@19.0.0)):
dependencies:
jotai: 2.10.3(@types/react@19.0.1)(react@19.0.0)
jotai@2.10.3(@types/react@19.0.1)(react@19.0.0): jotai@2.10.3(@types/react@19.0.1)(react@19.0.0):
optionalDependencies: optionalDependencies:
'@types/react': 19.0.1 '@types/react': 19.0.1

View File

@@ -16,13 +16,13 @@ import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon' import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors' import { grey } from '@mui/material/colors'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet } from 'react-router-dom'
import { settingsState } from './atoms/settings' import { settingsState } from './atoms/settings'
import AppBar from './components/AppBar' import AppBar from './components/AppBar'
import { AppTitle } from './components/AppTitle'
import Drawer from './components/Drawer' import Drawer from './components/Drawer'
import Footer from './components/Footer' import Footer from './components/Footer'
import Logout from './components/Logout' import Logout from './components/Logout'
@@ -76,7 +76,15 @@ export default function Layout() {
> >
<Menu /> <Menu />
</IconButton> </IconButton>
<AppTitle /> <Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
{settings.appTitle}
</Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer variant="permanent" open={open}> <Drawer variant="permanent" open={open}>

View File

@@ -79,4 +79,5 @@ keys:
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank). The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
cronExpressionLabel: 'Cron expression' cronExpressionLabel: 'Cron expression'
editButtonLabel: 'Edit' editButtonLabel: 'Edit'
newSubscriptionButton: New subscription newSubscriptionButton: New subscription
clearCompletedButton: 'Clear completed'

View File

@@ -1,12 +1,12 @@
import { getOrElse } from 'fp-ts/lib/Either' import { getOrElse } from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { atomWithCache } from 'jotai-cache' import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils' import { atomWithStorage } from 'jotai/utils'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { CustomTemplate } from '../types' import { CustomTemplate } from '../types'
import { serverSideCookiesState, serverURL } from './settings' import { serverSideCookiesState, serverURL } from './settings'
export const cookiesTemplateState = atomWithCache<Promise<string>>(async (get) => export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
await get(serverSideCookiesState) await get(serverSideCookiesState)
? '--cookies=cookies.txt' ? '--cookies=cookies.txt'
: '' : ''
@@ -22,7 +22,7 @@ export const filenameTemplateState = atomWithStorage(
localStorage.getItem('lastFilenameTemplate') ?? '' localStorage.getItem('lastFilenameTemplate') ?? ''
) )
export const savedTemplatesState = atomWithCache<Promise<CustomTemplate[]>>(async (get) => { export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`) const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
const either = await task() const either = await task()
@@ -30,4 +30,5 @@ export const savedTemplatesState = atomWithCache<Promise<CustomTemplate[]>>(asyn
either, either,
getOrElse(() => new Array<CustomTemplate>()) getOrElse(() => new Array<CustomTemplate>())
) )
}) }
)

View File

@@ -1,9 +1,9 @@
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { matchW } from 'fp-ts/lib/TaskEither' import { matchW } from 'fp-ts/lib/TaskEither'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { prefersDarkMode } from '../utils' import { prefersDarkMode } from '../utils'
import { atomWithStorage } from 'jotai/utils'
import { atom } from 'jotai'
export const languages = [ export const languages = [
'catalan', 'catalan',
@@ -122,14 +122,17 @@ export const serverAddressAndPortState = atom((get) => {
if (get(servedFromReverseProxySubDirState)) { if (get(servedFromReverseProxySubDirState)) {
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/` return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
.replaceAll('"', '') // XXX: atomWithStorage uses JSON.stringify to serialize .replaceAll('"', '') // XXX: atomWithStorage uses JSON.stringify to serialize
.replaceAll('//', '/') // which puts extra double quotes. .replaceAll('//', '/') // which puts extra double quotes.
} }
if (get(servedFromReverseProxyState)) { if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}` return `${get(serverAddressState)}`
.replaceAll('"', '') .replaceAll('"', '')
} }
return `${get(serverAddressState)}:${get(serverPortState)}`
const sap = `${get(serverAddressState)}:${get(serverPortState)}`
.replaceAll('"', '') .replaceAll('"', '')
return sap.endsWith('/') ? sap.slice(0, -1) : sap
}) })
export const serverURL = atom((get) => export const serverURL = atom((get) =>
@@ -138,12 +141,16 @@ export const serverURL = atom((get) =>
export const rpcWebSocketEndpoint = atom((get) => { export const rpcWebSocketEndpoint = atom((get) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws` const sap = get(serverAddressAndPortState)
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/ws`
}) })
export const rpcHTTPEndpoint = atom((get) => { export const rpcHTTPEndpoint = atom((get) => {
const proto = window.location.protocol const proto = window.location.protocol
return `${proto}//${get(serverAddressAndPortState)}/rpc/http` const sap = get(serverAddressAndPortState)
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/http`
}) })
export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe( export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe(

View File

@@ -1,33 +0,0 @@
import { Typography } from '@mui/material'
import { useAtom } from 'jotai'
import { useEffect } from 'react'
import { appTitleState } from '../atoms/settings'
import useFetch from '../hooks/useFetch'
export const AppTitle: React.FC = () => {
const [appTitle, setAppTitle] = useAtom(appTitleState)
const { data } = useFetch<{ title: string }>('/webconfig')
useEffect(() => {
if (data?.title) {
setAppTitle(
data.title.startsWith('"')
? data.title.substring(1, data.title.length - 1)
: data.title
)
}
}, [data])
return (
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
{appTitle.startsWith('"') ? appTitle.substring(1, appTitle.length - 1) : appTitle}
</Typography>
)
}

View File

@@ -1,5 +1,6 @@
import AddCircleIcon from '@mui/icons-material/AddCircle' import AddCircleIcon from '@mui/icons-material/AddCircle'
import BuildCircleIcon from '@mui/icons-material/BuildCircle' import BuildCircleIcon from '@mui/icons-material/BuildCircle'
import ClearAllIcon from '@mui/icons-material/ClearAll'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever' import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FolderZipIcon from '@mui/icons-material/FolderZip' import FolderZipIcon from '@mui/icons-material/FolderZip'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
@@ -31,6 +32,7 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
ariaLabel="Home speed dial" ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 64, right: 24 }} sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />} icon={<SpeedDialIcon />}
onClick={onDownloadOpen}
> >
<SpeedDialAction <SpeedDialAction
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />} icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
@@ -42,6 +44,11 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
tooltipTitle={i18n.t('bulkDownload')} tooltipTitle={i18n.t('bulkDownload')}
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)} onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
/> />
<SpeedDialAction
icon={<ClearAllIcon />}
tooltipTitle={i18n.t('clearCompletedButton')}
onClick={() => client.clearCompleted()}
/>
<SpeedDialAction <SpeedDialAction
icon={<DeleteForeverIcon />} icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')} tooltipTitle={i18n.t('abortAllButton')}

View File

@@ -15,7 +15,7 @@ import {
Typography Typography
} from '@mui/material' } from '@mui/material'
import { TransitionProps } from '@mui/material/transitions' import { TransitionProps } from '@mui/material/transitions'
import { matchW } from 'fp-ts/lib/TaskEither' import { matchW } from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { forwardRef, startTransition, useState } from 'react' import { forwardRef, startTransition, useState } from 'react'
@@ -52,16 +52,21 @@ const SubscriptionsDialog: React.FC<Props> = ({ open, onClose }) => {
const baseURL = useAtomValue(serverURL) const baseURL = useAtomValue(serverURL)
const submit = async (sub: Omit<Subscription, 'id'>) => pipe( const submit = async (sub: Omit<Subscription, 'id'>) => {
ffetch<void>(`${baseURL}/subscriptions`, { const task = ffetch<void>(`${baseURL}/subscriptions`, {
method: 'POST', method: 'POST',
body: JSON.stringify(sub) body: JSON.stringify(sub)
}), })
matchW( const either = await task()
(l) => pushMessage(l, 'error'),
(_) => onClose() pipe(
either,
matchW(
(l) => pushMessage(l, 'error'),
(_) => onClose()
)
) )
)() }
return ( return (
<Dialog <Dialog

View File

@@ -6,12 +6,6 @@ import { serverURL } from '../atoms/settings'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { useToast } from './toast' import { useToast } from './toast'
/**
* Wrapper hook for ffetch. Handles data retrieval and cancellation signals.
* If R type is set to void it doesn't perform deserialization.
* @param resource path of the resource. serverURL is prepended
* @returns JSON decoded value, eventual error and refetcher as an object to destruct.
*/
const useFetch = <R>(resource: string) => { const useFetch = <R>(resource: string) => {
const base = useAtomValue(serverURL) const base = useAtomValue(serverURL)
@@ -32,10 +26,7 @@ const useFetch = <R>(resource: string) => {
)() )()
useEffect(() => { useEffect(() => {
const controller = new AbortController()
fetcher() fetcher()
return () => controller.abort()
}, []) }, [])
return { data, error, fetcher } return { data, error, fetcher }

View File

@@ -25,6 +25,8 @@ async function fetcher(url: string, opt?: RequestInit, controller?: AbortControl
throw await res.text() throw await res.text()
} }
return res.text() return res.text()
} }

View File

@@ -200,4 +200,11 @@ export class RPCClient {
params: [] params: []
}) })
} }
public clearCompleted() {
return this.sendHTTP({
method: 'Service.ClearCompleted',
params: []
})
}
} }

View File

@@ -13,6 +13,7 @@ export type RPCMethods =
| "Service.ProgressLivestream" | "Service.ProgressLivestream"
| "Service.KillLivestream" | "Service.KillLivestream"
| "Service.KillAllLivestream" | "Service.KillAllLivestream"
| "Service.ClearCompleted"
export type RPCRequest = { export type RPCRequest = {
method: RPCMethods method: RPCMethods

View File

@@ -17,9 +17,7 @@ import {
Typography, Typography,
capitalize capitalize
} from '@mui/material' } from '@mui/material'
import { pipe } from 'fp-ts/lib/function' import { useAtom } from 'jotai'
import { matchW } from 'fp-ts/lib/TaskEither'
import { useAtom, useAtomValue } from 'jotai'
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import { import {
Subject, Subject,
@@ -36,9 +34,9 @@ import {
accentState, accentState,
accents, accents,
appTitleState, appTitleState,
autoFileExtensionState,
enableCustomArgsState, enableCustomArgsState,
fileRenamingState, fileRenamingState,
autoFileExtensionState,
formatSelectionState, formatSelectionState,
languageState, languageState,
languages, languages,
@@ -47,14 +45,12 @@ import {
servedFromReverseProxySubDirState, servedFromReverseProxySubDirState,
serverAddressState, serverAddressState,
serverPortState, serverPortState,
serverURL,
themeState themeState
} from '../atoms/settings' } from '../atoms/settings'
import CookiesTextField from '../components/CookiesTextField' import CookiesTextField from '../components/CookiesTextField'
import UpdateBinaryButton from '../components/UpdateBinaryButton' import UpdateBinaryButton from '../components/UpdateBinaryButton'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient'
import Translator from '../lib/i18n' import Translator from '../lib/i18n'
import { validateDomain, validateIP } from '../utils' import { validateDomain, validateIP } from '../utils'
@@ -74,7 +70,7 @@ export default function Settings() {
const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState) const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState)
const [language, setLanguage] = useAtom(languageState) const [language, setLanguage] = useAtom(languageState)
const [appTitle, setAppTitle] = useAtom(appTitleState) const [appTitle, setApptitle] = useAtom(appTitleState)
const [accent, setAccent] = useAtom(accentState) const [accent, setAccent] = useAtom(accentState)
const [theme, setTheme] = useAtom(themeState) const [theme, setTheme] = useAtom(themeState)
@@ -85,11 +81,7 @@ export default function Settings() {
const { pushMessage } = useToast() const { pushMessage } = useToast()
// TODO: change name
const derivedServerURL = useAtomValue(serverURL)
const baseURL$ = useMemo(() => new Subject<string>(), []) const baseURL$ = useMemo(() => new Subject<string>(), [])
const appTitle$ = useMemo(() => new Subject<string>(), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), []) const serverPort$ = useMemo(() => new Subject<string>(), [])
@@ -142,25 +134,6 @@ export default function Settings() {
return () => sub.unsubscribe() return () => sub.unsubscribe()
}, []) }, [])
// TODO: refactor out of component. maybe use withAtomEffect from jotai/effect package.
useEffect(() => {
const sub = appTitle$
.pipe(debounceTime(500))
.subscribe(title => {
pipe(
ffetch(`${derivedServerURL}/webconfig/title`, {
method: 'PATCH',
body: JSON.stringify(title)
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => setAppTitle(title)
)
)()
})
return () => sub.unsubscribe()
}, [])
/** /**
* Language toggler handler * Language toggler handler
*/ */
@@ -221,7 +194,7 @@ export default function Settings() {
fullWidth fullWidth
label={i18n.t('appTitle')} label={i18n.t('appTitle')}
defaultValue={appTitle} defaultValue={appTitle}
onChange={(e) => appTitle$.next(e.target.value)} onChange={(e) => setApptitle(e.currentTarget.value)}
error={appTitle === ''} error={appTitle === ''}
/> />
</Grid> </Grid>
@@ -245,7 +218,7 @@ export default function Settings() {
{ value: 500, label: '500 ms' }, { value: 500, label: '500 ms' },
{ value: 750, label: '750 ms' }, { value: 750, label: '750 ms' },
{ value: 1000, label: '1000 ms' }, { value: 1000, label: '1000 ms' },
{ value: 2000, label: '2 s' }, { value: 2000, label: '2000 ms' },
]} ]}
onChange={(_, value) => typeof value === 'number' onChange={(_, value) => typeof value === 'number'
? setPollingTime(value) ? setPollingTime(value)
@@ -394,7 +367,7 @@ export default function Settings() {
/> />
} }
label={i18n.t('autoFileExtensionOption')} label={i18n.t('autoFileExtensionOption')}
/> />
} }
<FormControlLabel <FormControlLabel
control={ control={

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/marcopiovanello/yt-dlp-web-ui/v3 module github.com/marcopiovanello/yt-dlp-web-ui/v3
go 1.23 go 1.24
require ( require (
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef

View File

@@ -4,32 +4,39 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Config struct { type Config struct {
LogPath string `yaml:"log_path"` LogPath string `yaml:"log_path"`
EnableFileLogging bool `yaml:"enable_file_logging"` EnableFileLogging bool `yaml:"enable_file_logging"`
BaseURL string `yaml:"base_url"` BaseURL string `yaml:"base_url"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"` DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"` DownloaderPath string `yaml:"downloaderPath"`
RequireAuth bool `yaml:"require_auth"` RequireAuth bool `yaml:"require_auth"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
QueueSize int `yaml:"queue_size"` QueueSize int `yaml:"queue_size"`
LocalDatabasePath string `yaml:"local_database_path"` LocalDatabasePath string `yaml:"local_database_path"`
SessionFilePath string `yaml:"session_file_path"` SessionFilePath string `yaml:"session_file_path"`
path string // private path string // private
UseOpenId bool `yaml:"use_openid"` UseOpenId bool `yaml:"use_openid"`
OpenIdProviderURL string `yaml:"openid_provider_url"` OpenIdProviderURL string `yaml:"openid_provider_url"`
OpenIdClientId string `yaml:"openid_client_id"` OpenIdClientId string `yaml:"openid_client_id"`
OpenIdClientSecret string `yaml:"openid_client_secret"` OpenIdClientSecret string `yaml:"openid_client_secret"`
OpenIdRedirectURL string `yaml:"openid_redirect_url"` OpenIdRedirectURL string `yaml:"openid_redirect_url"`
FrontendPath string `yaml:"frontend_path"` OpenIdEmailWhitelist []string `yaml:"openid_email_whitelist"`
AutoArchive bool `yaml:"auto_archive"` FrontendPath string `yaml:"frontend_path"`
AutoArchive bool `yaml:"auto_archive"`
Twitch struct {
ClientId string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
CheckInterval time.Duration `yaml:"check_interval"`
} `yaml:"twitch"`
} }
var ( var (

View File

@@ -1,104 +0,0 @@
package configurator
import (
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"gopkg.in/yaml.v3"
)
// A singleton holding configuration of the frontend component
// with optional persistence on a file.
type AppConfig struct {
Title string `yaml:"title" json:"title"`
BaseURL string `yaml:"base_url" json:"base_url"`
Language string `yaml:"language" json:"language"`
RPCPollingTime int `yaml:"rpc_polling_time" json:"rpc_polling_time"`
}
type Configurator struct {
mu sync.RWMutex
Config AppConfig
}
var (
instance *Configurator
instanceOnce sync.Once
)
func Instance() *Configurator {
instanceOnce.Do(func() {
if instance == nil {
instance = &Configurator{}
// TODO: move out of initialization
err := instance.Load()
if err != nil {
slog.Error("failed initializating configurator", slog.Any("err", err))
}
}
})
return instance
}
func (c *Configurator) Load() error {
fd, err := getConfigurationFile()
if err != nil {
return err
}
defer fd.Close()
if err := yaml.NewDecoder(fd).Decode(&c.Config); err != nil {
return err
}
return nil
}
func (c *Configurator) Persist() error {
fd, err := getConfigurationFile()
if err != nil {
return err
}
defer fd.Close()
if err := yaml.NewEncoder(fd).Encode(c.Config); err != nil {
return err
}
return nil
}
func (c *Configurator) setAppConfig(ac *AppConfig) {
c.mu.RLock()
defer c.mu.RUnlock()
// TODO: better validaitons
if ac.BaseURL != "" {
c.Config.BaseURL = ac.BaseURL
}
if ac.Language != "" {
c.Config.Language = ac.Language
}
if ac.Title != "" {
c.Config.Title = ac.Title
}
if ac.RPCPollingTime >= 250 && ac.RPCPollingTime <= 2000 {
c.Config.RPCPollingTime = ac.RPCPollingTime
}
}
func getConfigurationFile() (*os.File, error) {
fd, err := os.OpenFile(
filepath.Join(config.Instance().Dir(), "web_config.yml"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644,
)
if err != nil {
return nil, err
}
return fd, nil
}

View File

@@ -1,103 +0,0 @@
package configurator
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
)
// App configurator REST handlers
func GetConfig(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(Instance().Config); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func SetConfig(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
var req AppConfig
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Instance().setAppConfig(&req)
if err := Instance().Persist(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode("ok")
}
func setAppTitle(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
var req string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
editField(w, func(c *AppConfig) {
if req != "" {
c.Title = req
}
})
json.NewEncoder(w).Encode("ok")
}
func setBaseURL(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
var req string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
editField(w, func(c *AppConfig) {
if req != "" {
c.BaseURL = req
}
})
json.NewEncoder(w).Encode("ok")
}
func editField(w http.ResponseWriter, editFunc func(c *AppConfig)) {
editFunc(&Instance().Config)
if err := Instance().Persist(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func ApplyRouter() func(r chi.Router) {
return func(r chi.Router) {
r.Get("/", GetConfig)
r.Post("/", SetConfig)
r.Patch("/title", setAppTitle)
r.Patch("/baseURL", setBaseURL)
}
}

View File

@@ -2,6 +2,7 @@ package internal
import ( import (
"container/heap" "container/heap"
"log/slog"
) )
type LoadBalancer struct { type LoadBalancer struct {
@@ -9,7 +10,29 @@ type LoadBalancer struct {
done chan *Worker done chan *Worker
} }
func (b *LoadBalancer) Balance(work chan Process) { func NewLoadBalancer(numWorker int) *LoadBalancer {
var pool Pool
doneChan := make(chan *Worker)
for i := range numWorker {
w := &Worker{
requests: make(chan *Process, 1),
index: i,
}
go w.Work(doneChan)
pool = append(pool, w)
slog.Info("spawned worker", slog.Int("index", i))
}
return &LoadBalancer{
pool: pool,
done: doneChan,
}
}
func (b *LoadBalancer) Balance(work chan *Process) {
for { for {
select { select {
case req := <-work: case req := <-work:
@@ -20,7 +43,7 @@ func (b *LoadBalancer) Balance(work chan Process) {
} }
} }
func (b *LoadBalancer) dispatch(req Process) { func (b *LoadBalancer) dispatch(req *Process) {
w := heap.Pop(&b.pool).(*Worker) w := heap.Pop(&b.pool).(*Worker)
w.requests <- req w.requests <- req
w.pending++ w.pending++

View File

@@ -141,26 +141,13 @@ func (l *LiveStream) monitorStartTime(r io.Reader) {
} }
} }
const TRIES = 5 scanner.Scan()
/*
if it's waiting a livestream the 5th line will indicate the time to live
its a dumb and not robust method.
example: for !strings.Contains(scanner.Text(), "Waiting for") {
[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() scanner.Scan()
if strings.Contains(scanner.Text(), "Waiting for") {
waitTimeScanner()
}
} }
waitTimeScanner()
} }
func (l *LiveStream) WaitTime() <-chan time.Duration { func (l *LiveStream) WaitTime() <-chan time.Duration {

View File

@@ -9,15 +9,17 @@ import (
) )
func setupTest() { func setupTest() {
config.Instance().DownloaderPath = "yt-dlp" config.Instance().DownloaderPath = "build/yt-dlp"
} }
const URL = "https://www.youtube.com/watch?v=pwoAyLGOysU"
func TestLivestream(t *testing.T) { func TestLivestream(t *testing.T) {
setupTest() setupTest()
done := make(chan *LiveStream) done := make(chan *LiveStream)
ls := New("https://www.youtube.com/watch?v=LSm1daKezcE", done, &internal.MessageQueue{}, &internal.MemoryDB{}) ls := New(URL, done, &internal.MessageQueue{}, &internal.MemoryDB{})
go ls.Start() go ls.Start()
time.AfterFunc(time.Second*20, func() { time.AfterFunc(time.Second*20, func() {

View File

@@ -50,7 +50,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
return errors.New("probably not a valid URL") return errors.New("probably not a valid URL")
} }
if m.Type == "playlist" { if m.IsPlaylist() {
entries := slices.CompactFunc(slices.Compact(m.Entries), func(a common.DownloadInfo, b common.DownloadInfo) bool { entries := slices.CompactFunc(slices.Compact(m.Entries), func(a common.DownloadInfo, b common.DownloadInfo) bool {
return a.URL == b.URL return a.URL == b.URL
}) })

View File

@@ -1,16 +1,24 @@
package internal package internal
// Pool implements heap.Interface interface as a standard priority queue
type Pool []*Worker type Pool []*Worker
func (h Pool) Len() int { return len(h) } func (h Pool) Len() int { return len(h) }
func (h Pool) Less(i, j int) bool { return h[i].index < h[j].index } func (h Pool) Less(i, j int) bool { return h[i].pending < h[j].pending }
func (h Pool) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) } func (h Pool) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
h[i].index = i
h[j].index = j
}
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) }
func (h *Pool) Pop() any { func (h *Pool) Pop() any {
old := *h old := *h
n := len(old) n := len(old)
x := old[n-1] x := old[n-1]
old[n-1] = nil
*h = old[0 : n-1] *h = old[0 : n-1]
return x return x
} }

View File

@@ -100,6 +100,7 @@ func (p *Process) Start() {
templateReplacer.Replace(downloadTemplate), templateReplacer.Replace(downloadTemplate),
"--progress-template", "--progress-template",
templateReplacer.Replace(postprocessTemplate), templateReplacer.Replace(postprocessTemplate),
"--no-exec",
} }
// if user asked to manually override the output path... // if user asked to manually override the output path...

View File

@@ -1,9 +1,9 @@
package internal package internal
type Worker struct { type Worker struct {
requests chan Process // downloads to do requests chan *Process // downloads to do
pending int // downloads pending pending int // downloads pending
index int // index in the heap index int // index in the heap
} }
func (w *Worker) Work(done chan *Worker) { func (w *Worker) Work(done chan *Worker) {

View File

@@ -0,0 +1,20 @@
package middlewares
import (
"net/http"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid"
)
func ApplyAuthenticationByConfig(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if config.Instance().RequireAuth {
Authenticated(next)
}
if config.Instance().UseOpenId {
openid.Middleware(next)
}
next.ServeHTTP(w, r)
})
}

View File

@@ -6,10 +6,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"slices"
"time" "time"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@@ -76,6 +78,21 @@ func doAuthentification(r *http.Request, setCookieCallback func(t *oauth2.Token)
return nil, err return nil, err
} }
var claims struct {
Email string `json:"email"`
Verified bool `json:"email_verified"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
whitelist := config.Instance().OpenIdEmailWhitelist
if len(whitelist) > 0 && !slices.Contains(whitelist, claims.Email) {
return nil, errors.New("email address not found in ACL")
}
nonce, err := r.Cookie("nonce") nonce, err := r.Cookie("nonce")
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -8,3 +8,5 @@ type Metadata struct {
PlaylistTitle string `json:"title"` PlaylistTitle string `json:"title"`
Type string `json:"_type"` Type string `json:"_type"`
} }
func (m *Metadata) IsPlaylist() bool { return m.Type == "playlist" }

View File

@@ -183,6 +183,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
} }
slog.Info("succesfully killed process", slog.String("id", proc.Id)) slog.Info("succesfully killed process", slog.String("id", proc.Id))
proc = nil // gc helper
} }
return nil return nil
@@ -195,6 +196,35 @@ func (s *Service) Clear(args string, killed *string) error {
return nil return nil
} }
// Removes completed processes
func (s *Service) ClearCompleted(cleared *string) error {
var (
keys = s.db.Keys()
removeFunc = func(p *internal.Process) error {
defer s.db.Delete(p.Id)
if p.Progress.Status != internal.StatusCompleted {
return nil
}
return p.Kill()
}
)
for _, key := range *keys {
proc, err := s.db.Get(key)
if err != nil {
return err
}
if err := removeFunc(proc); err != nil {
return err
}
}
return nil
}
// FreeSpace gets the available from package sys util // FreeSpace gets the available from package sys util
func (s *Service) FreeSpace(args NoArgs, free *uint64) error { func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
freeSpace, err := sys.FreeSpace() freeSpace, err := sys.FreeSpace()

View File

@@ -22,7 +22,6 @@ import (
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/configurator"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/dbutil" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/dbutil"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/filebrowser" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/filebrowser"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
@@ -35,6 +34,7 @@ import (
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/twitch"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/user" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/user"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@@ -52,6 +52,7 @@ type serverConfig struct {
db *sql.DB db *sql.DB
mq *internal.MessageQueue mq *internal.MessageQueue
lm *livestream.Monitor lm *livestream.Monitor
tm *twitch.Monitor
} }
// TODO: change scope // TODO: change scope
@@ -116,17 +117,33 @@ func RunBlocking(rc *RunConfig) {
go lm.Schedule() go lm.Schedule()
go lm.Restore() go lm.Restore()
srv := newServer(serverConfig{ tm := twitch.NewMonitor(
twitch.NewAuthenticationManager(
config.Instance().Twitch.ClientId,
config.Instance().Twitch.ClientSecret,
),
)
go tm.Monitor(
context.TODO(),
config.Instance().Twitch.CheckInterval,
twitch.DEFAULT_DOWNLOAD_HANDLER(mdb, mq),
)
go tm.Restore()
scfg := serverConfig{
frontend: rc.App, frontend: rc.App,
swagger: rc.Swagger, swagger: rc.Swagger,
mdb: mdb, mdb: mdb,
mq: mq, mq: mq,
db: db, db: db,
lm: lm, lm: lm,
}) tm: tm,
}
go gracefulShutdown(srv, mdb) srv := newServer(scfg)
go autoPersist(time.Minute*5, mdb, lm)
go gracefulShutdown(srv, &scfg)
go autoPersist(time.Minute*5, mdb, lm, tm)
var ( var (
network = "tcp" network = "tcp"
@@ -189,12 +206,7 @@ func newServer(c serverConfig) *http.Server {
// Filebrowser routes // Filebrowser routes
r.Route("/filebrowser", func(r chi.Router) { r.Route("/filebrowser", func(r chi.Router) {
if config.Instance().RequireAuth { r.Use(middlewares.ApplyAuthenticationByConfig)
r.Use(middlewares.Authenticated)
}
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Post("/downloaded", filebrowser.ListDownloaded) r.Post("/downloaded", filebrowser.ListDownloaded)
r.Post("/delete", filebrowser.DeleteFile) r.Post("/delete", filebrowser.DeleteFile)
r.Get("/d/{id}", filebrowser.DownloadFile) r.Get("/d/{id}", filebrowser.DownloadFile)
@@ -236,13 +248,17 @@ func newServer(c serverConfig) *http.Server {
// Subscriptions // Subscriptions
r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter()) r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter())
// Frontend config store // Twitch
r.Route("/webconfig", configurator.ApplyRouter()) r.Route("/twitch", func(r chi.Router) {
r.Use(middlewares.ApplyAuthenticationByConfig)
r.Get("/all", twitch.GetMonitoredUsers(c.tm))
r.Post("/add", twitch.MonitorUserHandler(c.tm))
})
return &http.Server{Handler: r} return &http.Server{Handler: r}
} }
func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) { func gracefulShutdown(srv *http.Server, cfg *serverConfig) {
ctx, stop := signal.NotifyContext(context.Background(), ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, os.Interrupt,
syscall.SIGTERM, syscall.SIGTERM,
@@ -254,7 +270,9 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
slog.Info("shutdown signal received") slog.Info("shutdown signal received")
defer func() { defer func() {
db.Persist() cfg.mdb.Persist()
cfg.lm.Persist()
cfg.tm.Persist()
stop() stop()
srv.Shutdown(context.Background()) srv.Shutdown(context.Background())
@@ -262,8 +280,14 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
}() }()
} }
func autoPersist(d time.Duration, db *internal.MemoryDB, lm *livestream.Monitor) { func autoPersist(
d time.Duration,
db *internal.MemoryDB,
lm *livestream.Monitor,
tm *twitch.Monitor,
) {
for { for {
time.Sleep(d)
if err := db.Persist(); err != nil { if err := db.Persist(); err != nil {
slog.Warn("failed to persisted session", slog.Any("err", err)) slog.Warn("failed to persisted session", slog.Any("err", err))
} }
@@ -271,7 +295,10 @@ func autoPersist(d time.Duration, db *internal.MemoryDB, lm *livestream.Monitor)
slog.Warn( slog.Warn(
"failed to persisted livestreams monitor session", slog.Any("err", err.Error())) "failed to persisted livestreams monitor session", slog.Any("err", err.Error()))
} }
if err := tm.Persist(); err != nil {
slog.Warn(
"failed to persisted twitch monitor session", slog.Any("err", err.Error()))
}
slog.Debug("sucessfully persisted session") slog.Debug("sucessfully persisted session")
time.Sleep(d)
} }
} }

View File

@@ -48,7 +48,10 @@ func (h *RestHandler) Delete() http.HandlerFunc {
return return
} }
w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode("ok"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }

75
server/twitch/auth.go Normal file
View File

@@ -0,0 +1,75 @@
package twitch
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const authURL = "https://id.twitch.tv/oauth2/token"
type AuthResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
type AccessToken struct {
Token string
Expiry time.Time
}
type AuthenticationManager struct {
clientId string
clientSecret string
accesToken *AccessToken
}
func NewAuthenticationManager(clientId, clientSecret string) *AuthenticationManager {
return &AuthenticationManager{
clientId: clientId,
clientSecret: clientSecret,
accesToken: &AccessToken{},
}
}
func (a *AuthenticationManager) GetAccessToken() (*AccessToken, error) {
if a.accesToken != nil && a.accesToken.Token != "" && a.accesToken.Expiry.After(time.Now()) {
return a.accesToken, nil
}
data := url.Values{}
data.Set("client_id", a.clientId)
data.Set("client_secret", a.clientSecret)
data.Set("grant_type", "client_credentials")
resp, err := http.PostForm(authURL, data)
if err != nil {
return nil, fmt.Errorf("errore richiesta token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status non OK: %s", resp.Status)
}
var auth AuthResponse
if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil {
return nil, fmt.Errorf("errore decoding JSON: %w", err)
}
token := &AccessToken{
Token: auth.AccessToken,
Expiry: time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second),
}
a.accesToken = token
return token, nil
}
func (a *AuthenticationManager) GetClientId() string {
return a.clientId
}

91
server/twitch/client.go Normal file
View File

@@ -0,0 +1,91 @@
package twitch
import (
"encoding/json"
"io"
"net/http"
"time"
)
const twitchAPIURL = "https://api.twitch.tv/helix"
type Client struct {
authenticationManager AuthenticationManager
}
func NewTwitchClient(am *AuthenticationManager) *Client {
return &Client{
authenticationManager: *am,
}
}
type streamResp struct {
Data []struct {
ID string `json:"id"`
UserName string `json:"user_name"`
Title string `json:"title"`
GameName string `json:"game_name"`
StartedAt string `json:"started_at"`
} `json:"data"`
}
func (c *Client) doRequest(endpoint string, params map[string]string) ([]byte, error) {
token, err := c.authenticationManager.GetAccessToken()
if err != nil {
return nil, err
}
reqURL := twitchAPIURL + endpoint
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
for k, v := range params {
q.Set(k, v)
}
req.URL.RawQuery = q.Encode()
req.Header.Set("Client-Id", c.authenticationManager.GetClientId())
req.Header.Set("Authorization", "Bearer "+token.Token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func (c *Client) PollStream(channel string, liveChannel chan<- *StreamInfo) error {
body, err := c.doRequest("/streams", map[string]string{"user_login": channel})
if err != nil {
return err
}
var sr streamResp
if err := json.Unmarshal(body, &sr); err != nil {
return err
}
if len(sr.Data) == 0 {
liveChannel <- &StreamInfo{UserName: channel, IsLive: false}
return nil
}
s := sr.Data[0]
started, _ := time.Parse(time.RFC3339, s.StartedAt)
liveChannel <- &StreamInfo{
ID: s.ID,
UserName: s.UserName,
Title: s.Title,
GameName: s.GameName,
StartedAt: started,
IsLive: true,
}
return nil
}

142
server/twitch/monitor.go Normal file
View File

@@ -0,0 +1,142 @@
package twitch
import (
"context"
"encoding/gob"
"fmt"
"iter"
"log/slog"
"maps"
"os"
"path/filepath"
"sync"
"time"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
)
type Monitor struct {
liveChannel chan *StreamInfo
monitored map[string]*Client
lastState map[string]bool
mu sync.RWMutex
authenticationManager *AuthenticationManager
}
func NewMonitor(authenticationManager *AuthenticationManager) *Monitor {
return &Monitor{
liveChannel: make(chan *StreamInfo, 16),
monitored: make(map[string]*Client),
lastState: make(map[string]bool),
authenticationManager: authenticationManager,
}
}
func (m *Monitor) Add(user string) {
m.mu.Lock()
defer m.mu.Unlock()
m.monitored[user] = NewTwitchClient(m.authenticationManager)
slog.Info("added user to twitch monitor", slog.String("user", user))
}
func (m *Monitor) Monitor(ctx context.Context, interval time.Duration, handler func(url string) error) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.mu.RLock()
for user, client := range m.monitored {
u := user
c := client
go func() {
if err := c.PollStream(u, m.liveChannel); err != nil {
slog.Error("polling failed", slog.String("user", u), slog.Any("err", err))
}
}()
}
m.mu.RUnlock()
case stream := <-m.liveChannel:
wasLive := m.lastState[stream.UserName]
if stream.IsLive && !wasLive {
slog.Info("stream went live", slog.String("user", stream.UserName))
if err := handler(fmt.Sprintf("https://www.twitch.tv/%s", stream.UserName)); err != nil {
slog.Error("handler failed", slog.String("user", stream.UserName), slog.Any("err", err))
}
}
m.lastState[stream.UserName] = stream.IsLive
case <-ctx.Done():
slog.Info("stopping twitch monitor")
return
}
}
}
func (m *Monitor) GetMonitoredUsers() iter.Seq[string] {
m.mu.RLock()
defer m.mu.RUnlock()
return maps.Keys(m.monitored)
}
func DEFAULT_DOWNLOAD_HANDLER(db *internal.MemoryDB, mq *internal.MessageQueue) func(url string) error {
return func(url string) error {
p := &internal.Process{
Url: url,
Livestream: true,
Params: []string{"--downloader", "ffmpeg", "--no-part"},
}
db.Set(p)
mq.Publish(p)
return nil
}
}
func (m *Monitor) Persist() error {
filename := filepath.Join(config.Instance().SessionFilePath, "twitch-monitor.dat")
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
enc := gob.NewEncoder(f)
users := make([]string, 0, len(m.monitored))
for user := range m.monitored {
users = append(users, user)
}
return enc.Encode(users)
}
func (m *Monitor) Restore() error {
filename := filepath.Join(config.Instance().SessionFilePath, "twitch-monitor.dat")
f, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer f.Close()
dec := gob.NewDecoder(f)
var users []string
if err := dec.Decode(&users); err != nil {
return err
}
m.monitored = make(map[string]*Client)
for _, user := range users {
m.monitored[user] = NewTwitchClient(m.authenticationManager)
}
return nil
}

40
server/twitch/rest.go Normal file
View File

@@ -0,0 +1,40 @@
package twitch
import (
"encoding/json"
"net/http"
"slices"
)
type addUserReq struct {
User string `json:"user"`
}
func MonitorUserHandler(m *Monitor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req addUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
m.Add(req.User)
if err := json.NewEncoder(w).Encode("ok"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func GetMonitoredUsers(m *Monitor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
it := m.GetMonitoredUsers()
if err := json.NewEncoder(w).Encode(slices.Collect(it)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}

20
server/twitch/types.go Normal file
View File

@@ -0,0 +1,20 @@
package twitch
import "time"
type StreamInfo struct {
ID string
UserName string
Title string
GameName string
StartedAt time.Time
IsLive bool
}
type VodInfo struct {
ID string
Title string
URL string
Duration string
CreatedAt time.Time
}