Compare commits
1 Commits
master
...
feat-persi
| Author | SHA1 | Date | |
|---|---|---|---|
| 13c23303a9 |
@@ -1 +0,0 @@
|
||||
docker run -d -p 3033:3033 -v /downloads:/downloads marcobaobao/yt-dlp-webui
|
||||
@@ -3,7 +3,6 @@
|
||||
result/
|
||||
result
|
||||
dist
|
||||
.pnpm-store/
|
||||
.pnpm-debug.log
|
||||
node_modules
|
||||
.env
|
||||
@@ -21,11 +20,9 @@ cookies.txt
|
||||
__debug*
|
||||
ui/
|
||||
.idea
|
||||
.idea/
|
||||
frontend/.pnp.cjs
|
||||
frontend/.pnp.loader.mjs
|
||||
frontend/.yarn/install-state.gz
|
||||
.db.lock
|
||||
livestreams.dat
|
||||
.vite/deps
|
||||
archive.txt
|
||||
.git
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,4 +29,4 @@ frontend/.yarn/install-state.gz
|
||||
livestreams.dat
|
||||
.vite/deps
|
||||
archive.txt
|
||||
twitch-monitor.dat
|
||||
web_config.yml
|
||||
|
||||
@@ -24,12 +24,11 @@ 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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Runtime ---------------------------------------------------------------------
|
||||
FROM python:3.13.2-alpine3.21
|
||||
# dependencies ----------------------------------------------------------------
|
||||
FROM alpine:edge
|
||||
|
||||
RUN apk update && \
|
||||
apk add ffmpeg ca-certificates curl wget gnutls --no-cache && \
|
||||
pip install "yt-dlp[default,curl-cffi,mutagen,pycryptodomex,phantomjs,secretstorage]"
|
||||
apk add ffmpeg yt-dlp ca-certificates curl wget psmisc
|
||||
|
||||
VOLUME /downloads /config
|
||||
|
||||
@@ -40,4 +39,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
||||
ENV JWT_SECRET=secret
|
||||
|
||||
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" ]
|
||||
12
README.md
12
README.md
@@ -28,7 +28,7 @@ docker pull ghcr.io/marcopiovanello/yt-dlp-web-ui:latest
|
||||
## Community stuff
|
||||
Feel free to join :)
|
||||
|
||||
[Discord](https://discord.gg/GZAX5FfGzE)
|
||||
[](https://discord.gg/3Sj9ZZHv)
|
||||
|
||||
## Some screeshots
|
||||

|
||||
@@ -115,16 +115,6 @@ services:
|
||||
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
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "yt-dlp-webui",
|
||||
"version": "3.2.6",
|
||||
"version": "3.2.5",
|
||||
"description": "Frontend compontent of yt-dlp-webui",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@mui/material": "^6.2.0",
|
||||
"fp-ts": "^2.16.5",
|
||||
"jotai": "^2.10.3",
|
||||
"jotai-cache": "^0.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^6.23.1",
|
||||
|
||||
12
frontend/pnpm-lock.yaml
generated
12
frontend/pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
||||
jotai:
|
||||
specifier: ^2.10.3
|
||||
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:
|
||||
specifier: ^19.0.0
|
||||
version: 19.0.0
|
||||
@@ -737,6 +740,11 @@ packages:
|
||||
is-core-module@2.12.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-Nnf4IwrLhNfuz2JOQLI0V/AgwcpxvVy8Ec8PidIIDeRi4KCFpwTFIpHAAcU+yCgnw/oASYElq9UY0YdUUegsSA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -1512,6 +1520,10 @@ snapshots:
|
||||
dependencies:
|
||||
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):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.1
|
||||
|
||||
@@ -16,19 +16,18 @@ import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { grey } from '@mui/material/colors'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { settingsState } from './atoms/settings'
|
||||
import AppBar from './components/AppBar'
|
||||
import { AppTitle } from './components/AppTitle'
|
||||
import Drawer from './components/Drawer'
|
||||
import Footer from './components/Footer'
|
||||
import Logout from './components/Logout'
|
||||
import SocketSubscriber from './components/SocketSubscriber'
|
||||
import ThemeToggler from './components/ThemeToggler'
|
||||
import TwitchIcon from './components/TwitchIcon'
|
||||
import { useI18n } from './hooks/useI18n'
|
||||
import Toaster from './providers/ToasterProvider'
|
||||
import { getAccentValue } from './utils'
|
||||
@@ -77,15 +76,7 @@ export default function Layout() {
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h6"
|
||||
color="inherit"
|
||||
noWrap
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
{settings.appTitle}
|
||||
</Typography>
|
||||
<AppTitle />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer variant="permanent" open={open}>
|
||||
@@ -155,19 +146,6 @@ export default function Layout() {
|
||||
<ListItemText primary={i18n.t('subscriptionsButtonLabel')} />
|
||||
</ListItemButton>
|
||||
</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={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
|
||||
@@ -79,8 +79,4 @@ keys:
|
||||
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||
cronExpressionLabel: 'Cron expression'
|
||||
editButtonLabel: 'Edit'
|
||||
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
|
||||
newSubscriptionButton: New subscription
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getOrElse } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithCache } from 'jotai-cache'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { CustomTemplate } from '../types'
|
||||
import { serverSideCookiesState, serverURL } from './settings'
|
||||
|
||||
export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
|
||||
export const cookiesTemplateState = atomWithCache<Promise<string>>(async (get) =>
|
||||
await get(serverSideCookiesState)
|
||||
? '--cookies=cookies.txt'
|
||||
: ''
|
||||
@@ -22,7 +22,7 @@ export const filenameTemplateState = atomWithStorage(
|
||||
localStorage.getItem('lastFilenameTemplate') ?? ''
|
||||
)
|
||||
|
||||
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
|
||||
export const savedTemplatesState = atomWithCache<Promise<CustomTemplate[]>>(async (get) => {
|
||||
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
||||
const either = await task()
|
||||
|
||||
@@ -30,5 +30,4 @@ export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) =
|
||||
either,
|
||||
getOrElse(() => new Array<CustomTemplate>())
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { prefersDarkMode } from '../utils'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const languages = [
|
||||
'catalan',
|
||||
@@ -122,17 +122,14 @@ export const serverAddressAndPortState = atom((get) => {
|
||||
if (get(servedFromReverseProxySubDirState)) {
|
||||
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
|
||||
.replaceAll('"', '') // XXX: atomWithStorage uses JSON.stringify to serialize
|
||||
.replaceAll('//', '/') // which puts extra double quotes.
|
||||
.replaceAll('//', '/') // which puts extra double quotes.
|
||||
}
|
||||
if (get(servedFromReverseProxyState)) {
|
||||
return `${get(serverAddressState)}`
|
||||
.replaceAll('"', '')
|
||||
}
|
||||
|
||||
const sap = `${get(serverAddressState)}:${get(serverPortState)}`
|
||||
return `${get(serverAddressState)}:${get(serverPortState)}`
|
||||
.replaceAll('"', '')
|
||||
|
||||
return sap.endsWith('/') ? sap.slice(0, -1) : sap
|
||||
})
|
||||
|
||||
export const serverURL = atom((get) =>
|
||||
@@ -141,16 +138,12 @@ export const serverURL = atom((get) =>
|
||||
|
||||
export const rpcWebSocketEndpoint = atom((get) => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const sap = get(serverAddressAndPortState)
|
||||
|
||||
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/ws`
|
||||
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
|
||||
})
|
||||
|
||||
export const rpcHTTPEndpoint = atom((get) => {
|
||||
const proto = window.location.protocol
|
||||
const sap = get(serverAddressAndPortState)
|
||||
|
||||
return `${proto}//${sap.endsWith('/') ? sap.slice(0, -1) : sap}/rpc/http`
|
||||
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
|
||||
})
|
||||
|
||||
export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe(
|
||||
|
||||
33
frontend/src/components/AppTitle.tsx
Normal file
33
frontend/src/components/AppTitle.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
|
||||
import ClearAllIcon from '@mui/icons-material/ClearAll'
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||
import FolderZipIcon from '@mui/icons-material/FolderZip'
|
||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||
@@ -43,11 +42,6 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
||||
tooltipTitle={i18n.t('bulkDownload')}
|
||||
onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)}
|
||||
/>
|
||||
<SpeedDialAction
|
||||
icon={<ClearAllIcon />}
|
||||
tooltipTitle={i18n.t('clearCompletedButton')}
|
||||
onClick={() => client.clearCompleted()}
|
||||
/>
|
||||
<SpeedDialAction
|
||||
icon={<DeleteForeverIcon />}
|
||||
tooltipTitle={i18n.t('abortAllButton')}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { forwardRef, startTransition, useState } from 'react'
|
||||
@@ -52,21 +52,16 @@ const SubscriptionsDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const submit = async (sub: Omit<Subscription, 'id'>) => {
|
||||
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||
const submit = async (sub: Omit<Subscription, 'id'>) => pipe(
|
||||
ffetch<void>(`${baseURL}/subscriptions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(sub)
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => onClose()
|
||||
)
|
||||
}),
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => onClose()
|
||||
)
|
||||
}
|
||||
)()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
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
|
||||
@@ -6,6 +6,12 @@ import { serverURL } from '../atoms/settings'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
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 base = useAtomValue(serverURL)
|
||||
|
||||
@@ -26,7 +32,10 @@ const useFetch = <R>(resource: string) => {
|
||||
)()
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
fetcher()
|
||||
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return { data, error, fetcher }
|
||||
|
||||
@@ -25,8 +25,6 @@ async function fetcher(url: string, opt?: RequestInit, controller?: AbortControl
|
||||
throw await res.text()
|
||||
}
|
||||
|
||||
|
||||
|
||||
return res.text()
|
||||
}
|
||||
|
||||
|
||||
@@ -200,11 +200,4 @@ export class RPCClient {
|
||||
params: []
|
||||
})
|
||||
}
|
||||
|
||||
public clearCompleted() {
|
||||
return this.sendHTTP({
|
||||
method: 'Service.ClearCompleted',
|
||||
params: []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import Terminal from './views/Terminal'
|
||||
|
||||
const Home = lazy(() => import('./views/Home'))
|
||||
const Login = lazy(() => import('./views/Login'))
|
||||
const Twitch = lazy(() => import('./views/Twitch'))
|
||||
const Archive = lazy(() => import('./views/Archive'))
|
||||
const Settings = lazy(() => import('./views/Settings'))
|
||||
const LiveStream = lazy(() => import('./views/Livestream'))
|
||||
@@ -112,14 +111,6 @@ export const router = createHashRouter([
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/twitch',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Twitch />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
]
|
||||
},
|
||||
])
|
||||
@@ -13,7 +13,6 @@ export type RPCMethods =
|
||||
| "Service.ProgressLivestream"
|
||||
| "Service.KillLivestream"
|
||||
| "Service.KillAllLivestream"
|
||||
| "Service.ClearCompleted"
|
||||
|
||||
export type RPCRequest = {
|
||||
method: RPCMethods
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
Typography,
|
||||
capitalize
|
||||
} from '@mui/material'
|
||||
import { useAtom } from 'jotai'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Subject,
|
||||
@@ -34,9 +36,9 @@ import {
|
||||
accentState,
|
||||
accents,
|
||||
appTitleState,
|
||||
autoFileExtensionState,
|
||||
enableCustomArgsState,
|
||||
fileRenamingState,
|
||||
autoFileExtensionState,
|
||||
formatSelectionState,
|
||||
languageState,
|
||||
languages,
|
||||
@@ -45,12 +47,14 @@ import {
|
||||
servedFromReverseProxySubDirState,
|
||||
serverAddressState,
|
||||
serverPortState,
|
||||
serverURL,
|
||||
themeState
|
||||
} from '../atoms/settings'
|
||||
import CookiesTextField from '../components/CookiesTextField'
|
||||
import UpdateBinaryButton from '../components/UpdateBinaryButton'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import Translator from '../lib/i18n'
|
||||
import { validateDomain, validateIP } from '../utils'
|
||||
|
||||
@@ -70,7 +74,7 @@ export default function Settings() {
|
||||
|
||||
const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState)
|
||||
const [language, setLanguage] = useAtom(languageState)
|
||||
const [appTitle, setApptitle] = useAtom(appTitleState)
|
||||
const [appTitle, setAppTitle] = useAtom(appTitleState)
|
||||
const [accent, setAccent] = useAtom(accentState)
|
||||
|
||||
const [theme, setTheme] = useAtom(themeState)
|
||||
@@ -81,7 +85,11 @@ export default function Settings() {
|
||||
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
// TODO: change name
|
||||
const derivedServerURL = useAtomValue(serverURL)
|
||||
|
||||
const baseURL$ = useMemo(() => new Subject<string>(), [])
|
||||
const appTitle$ = useMemo(() => new Subject<string>(), [])
|
||||
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
||||
const serverPort$ = useMemo(() => new Subject<string>(), [])
|
||||
|
||||
@@ -134,6 +142,25 @@ export default function Settings() {
|
||||
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
|
||||
*/
|
||||
@@ -194,7 +221,7 @@ export default function Settings() {
|
||||
fullWidth
|
||||
label={i18n.t('appTitle')}
|
||||
defaultValue={appTitle}
|
||||
onChange={(e) => setApptitle(e.currentTarget.value)}
|
||||
onChange={(e) => appTitle$.next(e.target.value)}
|
||||
error={appTitle === ''}
|
||||
/>
|
||||
</Grid>
|
||||
@@ -218,7 +245,7 @@ export default function Settings() {
|
||||
{ value: 500, label: '500 ms' },
|
||||
{ value: 750, label: '750 ms' },
|
||||
{ value: 1000, label: '1000 ms' },
|
||||
{ value: 2000, label: '2000 ms' },
|
||||
{ value: 2000, label: '2 s' },
|
||||
]}
|
||||
onChange={(_, value) => typeof value === 'number'
|
||||
? setPollingTime(value)
|
||||
@@ -367,7 +394,7 @@ export default function Settings() {
|
||||
/>
|
||||
}
|
||||
label={i18n.t('autoFileExtensionOption')}
|
||||
/>
|
||||
/>
|
||||
}
|
||||
<FormControlLabel
|
||||
control={
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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
|
||||
|
||||
go 1.24
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
||||
|
||||
@@ -4,39 +4,32 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
LogPath string `yaml:"log_path"`
|
||||
EnableFileLogging bool `yaml:"enable_file_logging"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
DownloadPath string `yaml:"downloadPath"`
|
||||
DownloaderPath string `yaml:"downloaderPath"`
|
||||
RequireAuth bool `yaml:"require_auth"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
QueueSize int `yaml:"queue_size"`
|
||||
LocalDatabasePath string `yaml:"local_database_path"`
|
||||
SessionFilePath string `yaml:"session_file_path"`
|
||||
path string // private
|
||||
UseOpenId bool `yaml:"use_openid"`
|
||||
OpenIdProviderURL string `yaml:"openid_provider_url"`
|
||||
OpenIdClientId string `yaml:"openid_client_id"`
|
||||
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
||||
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
||||
OpenIdEmailWhitelist []string `yaml:"openid_email_whitelist"`
|
||||
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"`
|
||||
LogPath string `yaml:"log_path"`
|
||||
EnableFileLogging bool `yaml:"enable_file_logging"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
DownloadPath string `yaml:"downloadPath"`
|
||||
DownloaderPath string `yaml:"downloaderPath"`
|
||||
RequireAuth bool `yaml:"require_auth"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
QueueSize int `yaml:"queue_size"`
|
||||
LocalDatabasePath string `yaml:"local_database_path"`
|
||||
SessionFilePath string `yaml:"session_file_path"`
|
||||
path string // private
|
||||
UseOpenId bool `yaml:"use_openid"`
|
||||
OpenIdProviderURL string `yaml:"openid_provider_url"`
|
||||
OpenIdClientId string `yaml:"openid_client_id"`
|
||||
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
||||
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
||||
FrontendPath string `yaml:"frontend_path"`
|
||||
AutoArchive bool `yaml:"auto_archive"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -48,7 +41,6 @@ func Instance() *Config {
|
||||
if instance == nil {
|
||||
instanceOnce.Do(func() {
|
||||
instance = &Config{}
|
||||
instance.Twitch.CheckInterval = time.Minute * 5
|
||||
})
|
||||
}
|
||||
return instance
|
||||
|
||||
104
server/configurator/configurator.go
Normal file
104
server/configurator/configurator.go
Normal file
@@ -0,0 +1,104 @@
|
||||
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
|
||||
}
|
||||
103
server/configurator/handlers.go
Normal file
103
server/configurator/handlers.go
Normal file
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package internal
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type LoadBalancer struct {
|
||||
@@ -10,29 +9,7 @@ type LoadBalancer struct {
|
||||
done chan *Worker
|
||||
}
|
||||
|
||||
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) {
|
||||
func (b *LoadBalancer) Balance(work chan Process) {
|
||||
for {
|
||||
select {
|
||||
case req := <-work:
|
||||
@@ -43,7 +20,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.requests <- req
|
||||
w.pending++
|
||||
|
||||
@@ -141,13 +141,26 @@ func (l *LiveStream) monitorStartTime(r io.Reader) {
|
||||
}
|
||||
}
|
||||
|
||||
scanner.Scan()
|
||||
const TRIES = 5
|
||||
/*
|
||||
if it's waiting a livestream the 5th line will indicate the time to live
|
||||
its a dumb and not robust method.
|
||||
|
||||
for !strings.Contains(scanner.Text(), "Waiting for") {
|
||||
example:
|
||||
[youtube] Extracting URL: https://www.youtube.com/watch?v=IQVbGfVVjgY
|
||||
[youtube] IQVbGfVVjgY: Downloading webpage
|
||||
[youtube] IQVbGfVVjgY: Downloading ios player API JSON
|
||||
[youtube] IQVbGfVVjgY: Downloading web creator player API JSON
|
||||
WARNING: [youtube] This live event will begin in 27 minutes. <- STDERR, ignore
|
||||
[wait] Waiting for 00:27:15 - Press Ctrl+C to try now <- 5th line
|
||||
*/
|
||||
for range TRIES {
|
||||
scanner.Scan()
|
||||
}
|
||||
|
||||
waitTimeScanner()
|
||||
if strings.Contains(scanner.Text(), "Waiting for") {
|
||||
waitTimeScanner()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LiveStream) WaitTime() <-chan time.Duration {
|
||||
|
||||
@@ -9,17 +9,15 @@ import (
|
||||
)
|
||||
|
||||
func setupTest() {
|
||||
config.Instance().DownloaderPath = "build/yt-dlp"
|
||||
config.Instance().DownloaderPath = "yt-dlp"
|
||||
}
|
||||
|
||||
const URL = "https://www.youtube.com/watch?v=pwoAyLGOysU"
|
||||
|
||||
func TestLivestream(t *testing.T) {
|
||||
setupTest()
|
||||
|
||||
done := make(chan *LiveStream)
|
||||
|
||||
ls := New(URL, done, &internal.MessageQueue{}, &internal.MemoryDB{})
|
||||
ls := New("https://www.youtube.com/watch?v=LSm1daKezcE", done, &internal.MessageQueue{}, &internal.MemoryDB{})
|
||||
go ls.Start()
|
||||
|
||||
time.AfterFunc(time.Second*20, func() {
|
||||
|
||||
@@ -50,7 +50,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||
return errors.New("probably not a valid URL")
|
||||
}
|
||||
|
||||
if m.IsPlaylist() {
|
||||
if m.Type == "playlist" {
|
||||
entries := slices.CompactFunc(slices.Compact(m.Entries), func(a common.DownloadInfo, b common.DownloadInfo) bool {
|
||||
return a.URL == b.URL
|
||||
})
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
package internal
|
||||
|
||||
// Pool implements heap.Interface interface as a standard priority queue
|
||||
type Pool []*Worker
|
||||
|
||||
func (h Pool) Len() int { return len(h) }
|
||||
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]
|
||||
h[i].index = i
|
||||
h[j].index = j
|
||||
}
|
||||
|
||||
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) }
|
||||
func (h Pool) Less(i, j int) bool { return h[i].index < h[j].index }
|
||||
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) Pop() any {
|
||||
old := *h
|
||||
n := len(old)
|
||||
x := old[n-1]
|
||||
old[n-1] = nil
|
||||
*h = old[0 : n-1]
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ func (p *Process) Start() {
|
||||
templateReplacer.Replace(downloadTemplate),
|
||||
"--progress-template",
|
||||
templateReplacer.Replace(postprocessTemplate),
|
||||
"--no-exec",
|
||||
}
|
||||
|
||||
// if user asked to manually override the output path...
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package internal
|
||||
|
||||
type Worker struct {
|
||||
requests chan *Process // downloads to do
|
||||
pending int // downloads pending
|
||||
index int // index in the heap
|
||||
requests chan Process // downloads to do
|
||||
pending int // downloads pending
|
||||
index int // index in the heap
|
||||
}
|
||||
|
||||
func (w *Worker) Work(done chan *Worker) {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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,12 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -78,21 +76,6 @@ func doAuthentification(r *http.Request, setCookieCallback func(t *oauth2.Token)
|
||||
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")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -8,5 +8,3 @@ type Metadata struct {
|
||||
PlaylistTitle string `json:"title"`
|
||||
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) {
|
||||
//TODO: load from realease properties file, or anything else outside code
|
||||
const CURRENT_RPC_VERSION = "3.2.6"
|
||||
const CURRENT_RPC_VERSION = "3.2.5"
|
||||
|
||||
result := make(chan string, 1)
|
||||
|
||||
|
||||
@@ -183,7 +183,6 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
|
||||
}
|
||||
|
||||
slog.Info("succesfully killed process", slog.String("id", proc.Id))
|
||||
proc = nil // gc helper
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -196,35 +195,6 @@ func (s *Service) Clear(args string, killed *string) error {
|
||||
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
|
||||
func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
|
||||
freeSpace, err := sys.FreeSpace()
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"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/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/filebrowser"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
|
||||
@@ -34,7 +35,6 @@ import (
|
||||
"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/task"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/twitch"
|
||||
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/user"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -52,7 +52,6 @@ type serverConfig struct {
|
||||
db *sql.DB
|
||||
mq *internal.MessageQueue
|
||||
lm *livestream.Monitor
|
||||
tm *twitch.Monitor
|
||||
}
|
||||
|
||||
// TODO: change scope
|
||||
@@ -117,33 +116,17 @@ func RunBlocking(rc *RunConfig) {
|
||||
go lm.Schedule()
|
||||
go lm.Restore()
|
||||
|
||||
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{
|
||||
srv := newServer(serverConfig{
|
||||
frontend: rc.App,
|
||||
swagger: rc.Swagger,
|
||||
mdb: mdb,
|
||||
mq: mq,
|
||||
db: db,
|
||||
lm: lm,
|
||||
tm: tm,
|
||||
}
|
||||
})
|
||||
|
||||
srv := newServer(scfg)
|
||||
|
||||
go gracefulShutdown(srv, &scfg)
|
||||
go autoPersist(time.Minute*5, mdb, lm, tm)
|
||||
go gracefulShutdown(srv, mdb)
|
||||
go autoPersist(time.Minute*5, mdb, lm)
|
||||
|
||||
var (
|
||||
network = "tcp"
|
||||
@@ -206,7 +189,12 @@ func newServer(c serverConfig) *http.Server {
|
||||
|
||||
// Filebrowser routes
|
||||
r.Route("/filebrowser", func(r chi.Router) {
|
||||
r.Use(middlewares.ApplyAuthenticationByConfig)
|
||||
if config.Instance().RequireAuth {
|
||||
r.Use(middlewares.Authenticated)
|
||||
}
|
||||
if config.Instance().UseOpenId {
|
||||
r.Use(openid.Middleware)
|
||||
}
|
||||
r.Post("/downloaded", filebrowser.ListDownloaded)
|
||||
r.Post("/delete", filebrowser.DeleteFile)
|
||||
r.Get("/d/{id}", filebrowser.DownloadFile)
|
||||
@@ -248,18 +236,13 @@ func newServer(c serverConfig) *http.Server {
|
||||
// Subscriptions
|
||||
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))
|
||||
})
|
||||
// Frontend config store
|
||||
r.Route("/webconfig", configurator.ApplyRouter())
|
||||
|
||||
return &http.Server{Handler: r}
|
||||
}
|
||||
|
||||
func gracefulShutdown(srv *http.Server, cfg *serverConfig) {
|
||||
func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
@@ -271,9 +254,7 @@ func gracefulShutdown(srv *http.Server, cfg *serverConfig) {
|
||||
slog.Info("shutdown signal received")
|
||||
|
||||
defer func() {
|
||||
cfg.mdb.Persist()
|
||||
cfg.lm.Persist()
|
||||
cfg.tm.Persist()
|
||||
db.Persist()
|
||||
|
||||
stop()
|
||||
srv.Shutdown(context.Background())
|
||||
@@ -281,14 +262,8 @@ func gracefulShutdown(srv *http.Server, cfg *serverConfig) {
|
||||
}()
|
||||
}
|
||||
|
||||
func autoPersist(
|
||||
d time.Duration,
|
||||
db *internal.MemoryDB,
|
||||
lm *livestream.Monitor,
|
||||
tm *twitch.Monitor,
|
||||
) {
|
||||
func autoPersist(d time.Duration, db *internal.MemoryDB, lm *livestream.Monitor) {
|
||||
for {
|
||||
time.Sleep(d)
|
||||
if err := db.Persist(); err != nil {
|
||||
slog.Warn("failed to persisted session", slog.Any("err", err))
|
||||
}
|
||||
@@ -296,10 +271,7 @@ func autoPersist(
|
||||
slog.Warn(
|
||||
"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")
|
||||
time.Sleep(d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +48,7 @@ func (h *RestHandler) Delete() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode("ok"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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