Compare commits
29 Commits
feat-suppo
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c06485880 | ||
|
|
ccb6bbe3e6 | ||
| 9ca7bb9377 | |||
| bce696fc67 | |||
| 22caf8899b | |||
| 2a11f64935 | |||
|
|
f4a0f688af | ||
|
|
14a03d6a77 | ||
|
|
8a73079fad | ||
| f578f44cfd | |||
| cbe16c5c6c | |||
| 3cebaf7f61 | |||
|
|
2d2cb1dc3a | ||
|
|
43bcc40907 | ||
|
|
2af27e51be | ||
|
|
8c18242aaf | ||
|
|
66bebb2529 | ||
|
|
e223e030ac | ||
| e4362468f7 | |||
| 6880f60d14 | |||
|
|
5d4aa7e2a3 | ||
|
|
2845196bc7 | ||
|
|
983915f8aa | ||
| ce2fb13ef2 | |||
| 99069fe5f7 | |||
| 761f26b387 | |||
| eec72bb6e2 | |||
| ceb92d066c | |||
|
|
cf74948840 |
1
.deploystack/docker-run.txt
Normal file
1
.deploystack/docker-run.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
docker run -d -p 3033:3033 -v /downloads:/downloads marcobaobao/yt-dlp-webui
|
||||||
@@ -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
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ frontend/.yarn/install-state.gz
|
|||||||
livestreams.dat
|
livestreams.dat
|
||||||
.vite/deps
|
.vite/deps
|
||||||
archive.txt
|
archive.txt
|
||||||
|
twitch-monitor.dat
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -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 :)
|
||||||
|
|
||||||
[](https://discord.gg/3Sj9ZZHv)
|
[Discord](https://discord.gg/GZAX5FfGzE)
|
||||||
|
|
||||||
## Some screeshots
|
## Some screeshots
|
||||||

|

|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yt-dlp-webui",
|
"name": "yt-dlp-webui",
|
||||||
"version": "3.2.5",
|
"version": "3.2.6",
|
||||||
"description": "Frontend compontent of yt-dlp-webui",
|
"description": "Frontend compontent of yt-dlp-webui",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -18,11 +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",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import Footer from './components/Footer'
|
|||||||
import Logout from './components/Logout'
|
import Logout from './components/Logout'
|
||||||
import SocketSubscriber from './components/SocketSubscriber'
|
import SocketSubscriber from './components/SocketSubscriber'
|
||||||
import ThemeToggler from './components/ThemeToggler'
|
import ThemeToggler from './components/ThemeToggler'
|
||||||
|
import TwitchIcon from './components/TwitchIcon'
|
||||||
import { useI18n } from './hooks/useI18n'
|
import { useI18n } from './hooks/useI18n'
|
||||||
import Toaster from './providers/ToasterProvider'
|
import Toaster from './providers/ToasterProvider'
|
||||||
import { getAccentValue } from './utils'
|
import { getAccentValue } from './utils'
|
||||||
@@ -154,6 +155,19 @@ export default function Layout() {
|
|||||||
<ListItemText primary={i18n.t('subscriptionsButtonLabel')} />
|
<ListItemText primary={i18n.t('subscriptionsButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to={'/twitch'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<TwitchIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={"Twitch"} />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
<Link to={'/monitor'} style={
|
<Link to={'/monitor'} style={
|
||||||
{
|
{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
|
|||||||
@@ -80,3 +80,7 @@ keys:
|
|||||||
cronExpressionLabel: 'Cron expression'
|
cronExpressionLabel: 'Cron expression'
|
||||||
editButtonLabel: 'Edit'
|
editButtonLabel: 'Edit'
|
||||||
newSubscriptionButton: New subscription
|
newSubscriptionButton: New subscription
|
||||||
|
clearCompletedButton: 'Clear completed'
|
||||||
|
twitchIntegrationInfo: |
|
||||||
|
To enable monitoring Twitch streams follow this wiki page.
|
||||||
|
https://github.com/marcopiovanello/yt-dlp-web-ui/wiki/Twitch-integration
|
||||||
@@ -121,11 +121,18 @@ export const appTitleState = atomWithStorage(
|
|||||||
export const serverAddressAndPortState = atom((get) => {
|
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('//', '/') // which puts extra double quotes.
|
||||||
}
|
}
|
||||||
if (get(servedFromReverseProxyState)) {
|
if (get(servedFromReverseProxyState)) {
|
||||||
return `${get(serverAddressState)}`
|
return `${get(serverAddressState)}`
|
||||||
|
.replaceAll('"', '')
|
||||||
}
|
}
|
||||||
return `${get(serverAddressState)}:${get(serverPortState)}`
|
|
||||||
|
const sap = `${get(serverAddressState)}:${get(serverPortState)}`
|
||||||
|
.replaceAll('"', '')
|
||||||
|
|
||||||
|
return sap.endsWith('/') ? sap.slice(0, -1) : sap
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serverURL = atom((get) =>
|
export const serverURL = atom((get) =>
|
||||||
@@ -134,15 +141,17 @@ 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(
|
||||||
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
|
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
|
||||||
@@ -180,5 +189,4 @@ export const settingsState = atom<SettingsState>((get) => ({
|
|||||||
listView: get(listViewState),
|
listView: get(listViewState),
|
||||||
servedFromReverseProxy: get(servedFromReverseProxyState),
|
servedFromReverseProxy: get(servedFromReverseProxyState),
|
||||||
appTitle: get(appTitleState)
|
appTitle: get(appTitleState)
|
||||||
})
|
}))
|
||||||
)
|
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -42,6 +43,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')}
|
||||||
|
|||||||
22
frontend/src/components/TwitchIcon.tsx
Normal file
22
frontend/src/components/TwitchIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { settingsState } from '../atoms/settings'
|
||||||
|
|
||||||
|
const TwitchIcon: React.FC = () => {
|
||||||
|
const { theme } = useAtomValue(settingsState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ fill: theme === 'dark' ? '#fff' : '#757575' }}
|
||||||
|
>
|
||||||
|
<title>Twitch</title>
|
||||||
|
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchIcon
|
||||||
140
frontend/src/components/twitch/TwitchDialog.tsx
Normal file
140
frontend/src/components/twitch/TwitchDialog.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Slide,
|
||||||
|
TextField,
|
||||||
|
Toolbar,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { forwardRef, startTransition, useState } from 'react'
|
||||||
|
import { serverURL } from '../../atoms/settings'
|
||||||
|
import { useToast } from '../../hooks/toast'
|
||||||
|
import { useI18n } from '../../hooks/useI18n'
|
||||||
|
import { ffetch } from '../../lib/httpClient'
|
||||||
|
|
||||||
|
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 TwitchDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [channelURL, setChannelURL] = useState('')
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const submit = async (channelURL: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/twitch/user`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: channelURL.split('/').at(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => onClose()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullScreen
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
TransitionComponent={Transition}
|
||||||
|
>
|
||||||
|
<AppBar sx={{ position: 'relative' }}>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||||
|
{i18n.t('subscriptionsButtonLabel')}
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box sx={{
|
||||||
|
backgroundColor: (theme) => theme.palette.background.default,
|
||||||
|
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||||
|
}}>
|
||||||
|
<Container sx={{ my: 4 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container gap={1.5}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity="info">
|
||||||
|
{i18n.t('twitchIntegrationInfo')}
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} mt={1}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('subscriptionsURLInput')}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="https://www.twitch.tv/a_twitch_user_that_exists"
|
||||||
|
onChange={(e) => setChannelURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
variant="contained"
|
||||||
|
disabled={channelURL === ''}
|
||||||
|
onClick={() => startTransition(() => submit(channelURL))}
|
||||||
|
>
|
||||||
|
{i18n.t('startButton')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchDialog
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { tryCatch } from 'fp-ts/TaskEither'
|
import { tryCatch } from 'fp-ts/TaskEither'
|
||||||
|
import * as J from 'fp-ts/Json'
|
||||||
|
import * as E from 'fp-ts/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
|
||||||
async function fetcher<T>(url: string, opt?: RequestInit): Promise<T> {
|
async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise<string> {
|
||||||
const jwt = localStorage.getItem('token')
|
const jwt = localStorage.getItem('token')
|
||||||
|
|
||||||
if (opt && !opt.headers) {
|
if (opt && !opt.headers) {
|
||||||
@@ -14,17 +17,27 @@ async function fetcher<T>(url: string, opt?: RequestInit): Promise<T> {
|
|||||||
headers: {
|
headers: {
|
||||||
...opt?.headers,
|
...opt?.headers,
|
||||||
'X-Authentication': jwt ?? ''
|
'X-Authentication': jwt ?? ''
|
||||||
}
|
},
|
||||||
|
signal: controller?.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw await res.text()
|
throw await res.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as T
|
|
||||||
|
|
||||||
|
return res.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
export const ffetch = <T>(url: string, opt?: RequestInit, controller?: AbortController) => tryCatch(
|
||||||
() => fetcher<T>(url, opt),
|
async () => pipe(
|
||||||
|
await fetcher(url, opt, controller),
|
||||||
|
J.parse,
|
||||||
|
E.match(
|
||||||
|
(l) => l as T,
|
||||||
|
(r) => r as T
|
||||||
|
)
|
||||||
|
),
|
||||||
(e) => `error while fetching: ${e}`
|
(e) => `error while fetching: ${e}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -200,4 +200,11 @@ export class RPCClient {
|
|||||||
params: []
|
params: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public clearCompleted() {
|
||||||
|
return this.sendHTTP({
|
||||||
|
method: 'Service.ClearCompleted',
|
||||||
|
params: []
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import Terminal from './views/Terminal'
|
|||||||
|
|
||||||
const Home = lazy(() => import('./views/Home'))
|
const Home = lazy(() => import('./views/Home'))
|
||||||
const Login = lazy(() => import('./views/Login'))
|
const Login = lazy(() => import('./views/Login'))
|
||||||
|
const Twitch = lazy(() => import('./views/Twitch'))
|
||||||
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 LiveStream = lazy(() => import('./views/Livestream'))
|
||||||
@@ -111,6 +112,14 @@ export const router = createHashRouter([
|
|||||||
</Suspense >
|
</Suspense >
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/twitch',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Twitch />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -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
|
||||||
|
|||||||
77
frontend/src/views/Twitch.tsx
Normal file
77
frontend/src/views/Twitch.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Chip,
|
||||||
|
Container,
|
||||||
|
Paper
|
||||||
|
} from '@mui/material'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||||
|
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||||
|
import TwitchDialog from '../components/twitch/TwitchDialog'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import useFetch from '../hooks/useFetch'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
|
||||||
|
const TwitchView: React.FC = () => {
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const baseURL = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const { data: users, fetcher: refetch } = useFetch<Array<string>>('/twitch/users')
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const deleteUser = async (user: string) => {
|
||||||
|
const task = ffetch<void>(`${baseURL}/twitch/user/${user}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
() => refetch()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingBackdrop isLoading={!users || isPending} />
|
||||||
|
|
||||||
|
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||||
|
|
||||||
|
<TwitchDialog open={openDialog} onClose={() => {
|
||||||
|
setOpenDialog(s => !s)
|
||||||
|
refetch()
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{
|
||||||
|
!users || users.length === 0 ?
|
||||||
|
<NoSubscriptions /> :
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||||
|
<Paper sx={{
|
||||||
|
p: 2.5,
|
||||||
|
minHeight: '80vh',
|
||||||
|
}}>
|
||||||
|
{users.map(user => (
|
||||||
|
<Chip
|
||||||
|
label={user}
|
||||||
|
onDelete={() => startTransition(async () => await deleteUser(user))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TwitchView
|
||||||
2
go.mod
2
go.mod
@@ -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
|
||||||
|
|||||||
58
server/archive/utils.go
Normal file
58
server/archive/utils.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Perform a search on the archive.txt file an determines if a download
|
||||||
|
// has already be done.
|
||||||
|
func DownloadExists(ctx context.Context, url string) (bool, error) {
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
config.Instance().DownloaderPath,
|
||||||
|
"--print",
|
||||||
|
"%(extractor)s %(id)s",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extractorAndURL := bytes.Trim(stdout, "\n")
|
||||||
|
|
||||||
|
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "archive.txt"))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer fd.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(fd)
|
||||||
|
|
||||||
|
// search linearly for lower memory usage...
|
||||||
|
// the a pre-sorted with hashed values version of the archive.txt file can be loaded in memory
|
||||||
|
// and perform a binary search on it.
|
||||||
|
for scanner.Scan() {
|
||||||
|
if bytes.Equal(scanner.Bytes(), extractorAndURL) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// data, err := io.ReadAll(fd)
|
||||||
|
// if err != nil {
|
||||||
|
// return false, err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// slices.BinarySearchFunc(data, extractorAndURL, func(a []byte, b []byte) int {
|
||||||
|
// return hash(a).Compare(hash(b))
|
||||||
|
// })
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
@@ -41,6 +48,7 @@ func Instance() *Config {
|
|||||||
if instance == nil {
|
if instance == nil {
|
||||||
instanceOnce.Do(func() {
|
instanceOnce.Do(func() {
|
||||||
instance = &Config{}
|
instance = &Config{}
|
||||||
|
instance.Twitch.CheckInterval = time.Minute * 5
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return instance
|
return instance
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (m *Monitor) Status() LiveStreamStatus {
|
|||||||
// Persist the monitor current state to a file.
|
// Persist the monitor current state to a file.
|
||||||
// The file is located in the configured config directory
|
// The file is located in the configured config directory
|
||||||
func (m *Monitor) Persist() error {
|
func (m *Monitor) Persist() error {
|
||||||
fd, err := os.Create(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
|
fd, err := os.Create(filepath.Join(config.Instance().SessionFilePath, "livestreams.dat"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ func (m *Monitor) Persist() error {
|
|||||||
|
|
||||||
// Restore a saved state and resume the monitored livestreams
|
// Restore a saved state and resume the monitored livestreams
|
||||||
func (m *Monitor) Restore() error {
|
func (m *Monitor) Restore() error {
|
||||||
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
|
fd, err := os.Open(filepath.Join(config.Instance().SessionFilePath, "livestreams.dat"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
@@ -88,10 +88,10 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
|||||||
|
|
||||||
proc.Info.URL = meta.URL
|
proc.Info.URL = meta.URL
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
|
|
||||||
db.Set(proc)
|
db.Set(proc)
|
||||||
mq.Publish(proc)
|
mq.Publish(proc)
|
||||||
|
|
||||||
|
proc.Info.CreatedAt = meta.CreatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
21
server/middleware/utils.go
Normal file
21
server/middleware/utils.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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 {
|
||||||
|
handler := next
|
||||||
|
|
||||||
|
if config.Instance().RequireAuth {
|
||||||
|
handler = Authenticated(handler)
|
||||||
|
}
|
||||||
|
if config.Instance().UseOpenId {
|
||||||
|
handler = openid.Middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
func (s *Service) GetVersion(ctx context.Context) (string, string, error) {
|
func (s *Service) GetVersion(ctx context.Context) (string, string, error) {
|
||||||
//TODO: load from realease properties file, or anything else outside code
|
//TODO: load from realease properties file, or anything else outside code
|
||||||
const CURRENT_RPC_VERSION = "3.2.5"
|
const CURRENT_RPC_VERSION = "3.2.6"
|
||||||
|
|
||||||
result := make(chan string, 1)
|
result := make(chan string, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -34,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"
|
||||||
@@ -51,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
|
||||||
@@ -115,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"
|
||||||
@@ -188,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)
|
||||||
@@ -235,10 +248,18 @@ 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())
|
||||||
|
|
||||||
|
// Twitch
|
||||||
|
r.Route("/twitch", func(r chi.Router) {
|
||||||
|
r.Use(middlewares.ApplyAuthenticationByConfig)
|
||||||
|
r.Get("/users", twitch.GetMonitoredUsers(c.tm))
|
||||||
|
r.Post("/user", twitch.MonitorUserHandler(c.tm))
|
||||||
|
r.Delete("/user/{user}", twitch.DeleteUser(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,
|
||||||
@@ -250,7 +271,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())
|
||||||
@@ -258,8 +281,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))
|
||||||
}
|
}
|
||||||
@@ -267,7 +296,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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func toDB(dto *domain.Subscription) data.Subscription {
|
|||||||
|
|
||||||
// Delete implements domain.Service.
|
// Delete implements domain.Service.
|
||||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||||
|
s.runner.StopTask(id)
|
||||||
return s.r.Delete(ctx, id)
|
return s.r.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive"
|
||||||
"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/internal"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
|
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain"
|
||||||
@@ -19,10 +19,12 @@ import (
|
|||||||
type TaskRunner interface {
|
type TaskRunner interface {
|
||||||
Submit(subcription *domain.Subscription) error
|
Submit(subcription *domain.Subscription) error
|
||||||
Spawner(ctx context.Context)
|
Spawner(ctx context.Context)
|
||||||
|
StopTask(id string) error
|
||||||
Recoverer()
|
Recoverer()
|
||||||
}
|
}
|
||||||
|
|
||||||
type taskPair struct {
|
type monitorTask struct {
|
||||||
|
Done chan struct{}
|
||||||
Schedule cron.Schedule
|
Schedule cron.Schedule
|
||||||
Subscription *domain.Subscription
|
Subscription *domain.Subscription
|
||||||
}
|
}
|
||||||
@@ -31,21 +33,22 @@ type CronTaskRunner struct {
|
|||||||
mq *internal.MessageQueue
|
mq *internal.MessageQueue
|
||||||
db *internal.MemoryDB
|
db *internal.MemoryDB
|
||||||
|
|
||||||
tasks chan taskPair
|
tasks chan monitorTask
|
||||||
errors chan error
|
errors chan error
|
||||||
|
|
||||||
|
running map[string]*monitorTask
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCronTaskRunner(mq *internal.MessageQueue, db *internal.MemoryDB) TaskRunner {
|
func NewCronTaskRunner(mq *internal.MessageQueue, db *internal.MemoryDB) TaskRunner {
|
||||||
return &CronTaskRunner{
|
return &CronTaskRunner{
|
||||||
mq: mq,
|
mq: mq,
|
||||||
db: db,
|
db: db,
|
||||||
tasks: make(chan taskPair),
|
tasks: make(chan monitorTask),
|
||||||
errors: make(chan error),
|
errors: make(chan error),
|
||||||
|
running: make(map[string]*monitorTask),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandTemplate = "-I1 --flat-playlist --print webpage_url $1"
|
|
||||||
|
|
||||||
var argsSplitterRe = regexp.MustCompile(`(?mi)[^\s"']+|"([^"]*)"|'([^']*)'`)
|
var argsSplitterRe = regexp.MustCompile(`(?mi)[^\s"']+|"([^"]*)"|'([^']*)'`)
|
||||||
|
|
||||||
func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error {
|
func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error {
|
||||||
@@ -54,7 +57,8 @@ func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
job := taskPair{
|
job := monitorTask{
|
||||||
|
Done: make(chan struct{}),
|
||||||
Schedule: schedule,
|
Schedule: schedule,
|
||||||
Subscription: subcription,
|
Subscription: subcription,
|
||||||
}
|
}
|
||||||
@@ -64,54 +68,110 @@ func (t *CronTaskRunner) Submit(subcription *domain.Subscription) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handles the entire lifecylce of a monitor job.
|
||||||
func (t *CronTaskRunner) Spawner(ctx context.Context) {
|
func (t *CronTaskRunner) Spawner(ctx context.Context) {
|
||||||
for task := range t.tasks {
|
for req := range t.tasks {
|
||||||
|
t.running[req.Subscription.Id] = &req // keep track of the current job
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
ctx, cancel := context.WithCancel(ctx) // inject into the job's context a cancellation singal
|
||||||
|
fetcherEvents := t.doFetch(ctx, &req) // retrieve the channel of events of the job
|
||||||
|
|
||||||
for {
|
for {
|
||||||
slog.Info("fetching latest video for channel", slog.String("channel", task.Subscription.URL))
|
select {
|
||||||
|
case <-req.Done:
|
||||||
fetcherParams := strings.Split(strings.Replace(commandTemplate, "$1", task.Subscription.URL, 1), " ")
|
slog.Info("stopping cron job and removing schedule", slog.String("url", req.Subscription.URL))
|
||||||
|
cancel()
|
||||||
cmd := exec.CommandContext(
|
|
||||||
ctx,
|
|
||||||
config.Instance().DownloaderPath,
|
|
||||||
fetcherParams...,
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
t.errors <- err
|
|
||||||
return
|
return
|
||||||
|
case <-fetcherEvents:
|
||||||
|
slog.Info("finished monitoring channel", slog.String("url", req.Subscription.URL))
|
||||||
}
|
}
|
||||||
|
|
||||||
latestChannelURL := string(bytes.Trim(stdout, "\n"))
|
|
||||||
|
|
||||||
p := &internal.Process{
|
|
||||||
Url: latestChannelURL,
|
|
||||||
Params: append(argsSplitterRe.FindAllString(task.Subscription.Params, 1), []string{
|
|
||||||
"--download-archive",
|
|
||||||
filepath.Join(config.Instance().Dir(), "archive.txt"),
|
|
||||||
}...),
|
|
||||||
AutoRemove: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.db.Set(p)
|
|
||||||
t.mq.Publish(p)
|
|
||||||
|
|
||||||
nextSchedule := time.Until(task.Schedule.Next(time.Now()))
|
|
||||||
|
|
||||||
slog.Info(
|
|
||||||
"cron task runner next schedule",
|
|
||||||
slog.String("url", task.Subscription.URL),
|
|
||||||
slog.Any("duration", nextSchedule),
|
|
||||||
)
|
|
||||||
|
|
||||||
time.Sleep(nextSchedule)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTaskRunner) Recoverer() {
|
// Stop a currently scheduled job
|
||||||
panic("Unimplemented")
|
func (t *CronTaskRunner) StopTask(id string) error {
|
||||||
|
task := t.running[id]
|
||||||
|
if task != nil {
|
||||||
|
t.running[id].Done <- struct{}{}
|
||||||
|
delete(t.running, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a fetcher and notify on a channel when a fetcher has completed
|
||||||
|
func (t *CronTaskRunner) doFetch(ctx context.Context, req *monitorTask) <-chan struct{} {
|
||||||
|
completed := make(chan struct{})
|
||||||
|
|
||||||
|
// generator func
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
sleepFor := t.fetcher(ctx, req)
|
||||||
|
completed <- struct{}{}
|
||||||
|
|
||||||
|
time.Sleep(sleepFor)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return completed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the retrieval of the latest video of the channel.
|
||||||
|
// Returns a time.Duration containing the amount of time to the next schedule.
|
||||||
|
func (t *CronTaskRunner) fetcher(ctx context.Context, req *monitorTask) time.Duration {
|
||||||
|
slog.Info("fetching latest video for channel", slog.String("channel", req.Subscription.URL))
|
||||||
|
|
||||||
|
nextSchedule := time.Until(req.Schedule.Next(time.Now()))
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
config.Instance().DownloaderPath,
|
||||||
|
"-I1",
|
||||||
|
"--flat-playlist",
|
||||||
|
"--print", "webpage_url",
|
||||||
|
req.Subscription.URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.errors <- err
|
||||||
|
return time.Duration(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
latestVideoURL := string(bytes.Trim(stdout, "\n"))
|
||||||
|
|
||||||
|
// if the download exists there's not point in sending it into the message queue.
|
||||||
|
exists, err := archive.DownloadExists(ctx, latestVideoURL)
|
||||||
|
if exists && err == nil {
|
||||||
|
return nextSchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &internal.Process{
|
||||||
|
Url: latestVideoURL,
|
||||||
|
Params: append(
|
||||||
|
argsSplitterRe.FindAllString(req.Subscription.Params, 1),
|
||||||
|
[]string{
|
||||||
|
"--break-on-existing",
|
||||||
|
"--download-archive",
|
||||||
|
filepath.Join(config.Instance().Dir(), "archive.txt"),
|
||||||
|
}...),
|
||||||
|
AutoRemove: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.db.Set(p) // give it an id
|
||||||
|
t.mq.Publish(p) // send it to the message queue waiting to be processed
|
||||||
|
|
||||||
|
slog.Info(
|
||||||
|
"cron task runner next schedule",
|
||||||
|
slog.String("url", req.Subscription.URL),
|
||||||
|
slog.Any("duration", nextSchedule),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nextSchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTaskRunner) Recoverer() {
|
||||||
|
panic("unimplemented")
|
||||||
}
|
}
|
||||||
|
|||||||
75
server/twitch/auth.go
Normal file
75
server/twitch/auth.go
Normal 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
91
server/twitch/client.go
Normal 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
|
||||||
|
}
|
||||||
149
server/twitch/monitor.go
Normal file
149
server/twitch/monitor.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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 (m *Monitor) DeleteUser(user string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.monitored, user)
|
||||||
|
delete(m.lastState, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
65
server/twitch/rest.go
Normal file
65
server/twitch/rest.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package twitch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
users := slices.Collect(it)
|
||||||
|
if users == nil {
|
||||||
|
users = make([]string, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(users); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteUser(m *Monitor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := chi.URLParam(r, "user")
|
||||||
|
|
||||||
|
if user == "" {
|
||||||
|
http.Error(w, "empty user", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.DeleteUser(user)
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode("ok"); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
server/twitch/types.go
Normal file
20
server/twitch/types.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user