Compare commits

...

8 Commits

Author SHA1 Message Date
13c23303a9 persist app title + code refactoring 2025-02-13 14:02:00 +01:00
LelieL91
983915f8aa Fixed static file location (#263)
* Update EN, IT langs

Fixed EN lang mistype error
Added missing IT keys + added more translations

* Fixed files location

- livestreams.dat now uses same location as session.data (if specified on config.yml)
- .db.lock now uses same location as database file (if specified on config.yml)

* Update migrate.go

revert edit

---------

Co-authored-by: Marco Piovanello <35533749+marcopiovanello@users.noreply.github.com>
2025-02-07 22:00:11 +01:00
ce2fb13ef2 code refactoring 2025-02-07 10:13:35 +01:00
99069fe5f7 fixed proxy subdir malformed string 2025-02-07 09:45:26 +01:00
761f26b387 subscriptions: prevent downloading already existing file 2025-02-07 09:37:47 +01:00
eec72bb6e2 handle cancellation of scheduled cron jobs 2025-02-06 19:28:03 +01:00
ceb92d066c code refactoring 2025-02-06 19:27:38 +01:00
Marco Piovanello
cf74948840 initial support for playlist modifiers (#262)
supported modifiers are --playlist-start, --playlist-end, --playlist-reverse, --max-downloads
2025-02-06 11:30:28 +01:00
26 changed files with 659 additions and 141 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ keys:
splashText: No active downloads splashText: No active downloads
archiveTitle: Archive archiveTitle: Archive
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window) playlistCheckbox: Download playlist
restartAppMessage: Needs a page reload to take effect restartAppMessage: Needs a page reload to take effect
servedFromReverseProxyCheckbox: Is behind a reverse proxy servedFromReverseProxyCheckbox: Is behind a reverse proxy
urlBase: URL base, for reverse proxy support (subdir), defaults to empty urlBase: URL base, for reverse proxy support (subdir), defaults to empty

View File

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

View File

@@ -1,9 +1,9 @@
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { matchW } from 'fp-ts/lib/TaskEither' import { matchW } from 'fp-ts/lib/TaskEither'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { prefersDarkMode } from '../utils' import { prefersDarkMode } from '../utils'
import { atomWithStorage } from 'jotai/utils'
import { atom } from 'jotai'
export const languages = [ export const languages = [
'catalan', 'catalan',
@@ -121,11 +121,15 @@ 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)}` return `${get(serverAddressState)}:${get(serverPortState)}`
.replaceAll('"', '')
}) })
export const serverURL = atom((get) => export const serverURL = atom((get) =>
@@ -135,14 +139,12 @@ 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` return `${proto}//${get(serverAddressAndPortState)}/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` return `${proto}//${get(serverAddressAndPortState)}/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 +182,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)
}) }))
)

View 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>
)
}

View File

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

View File

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

View File

@@ -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,25 @@ 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}`
) )

View File

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

58
server/archive/utils.go Normal file
View 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
}

18
server/common/types.go Normal file
View File

@@ -0,0 +1,18 @@
package common
import "time"
// Used to deser the yt-dlp -J output
type DownloadInfo struct {
URL string `json:"url"`
Title string `json:"title"`
Thumbnail string `json:"thumbnail"`
Resolution string `json:"resolution"`
Size int32 `json:"filesize_approx"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Extension string `json:"ext"`
OriginalURL string `json:"original_url"`
FileName string `json:"filename"`
CreatedAt time.Time `json:"created_at"`
}

View 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
}

View 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)
}
}

View File

@@ -1,6 +1,8 @@
package internal package internal
import "time" import (
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
)
// Used to unmarshall yt-dlp progress // Used to unmarshall yt-dlp progress
type ProgressTemplate struct { type ProgressTemplate struct {
@@ -29,27 +31,12 @@ type DownloadProgress struct {
ETA float64 `json:"eta"` ETA float64 `json:"eta"`
} }
// Used to deser the yt-dlp -J output
type DownloadInfo struct {
URL string `json:"url"`
Title string `json:"title"`
Thumbnail string `json:"thumbnail"`
Resolution string `json:"resolution"`
Size int32 `json:"filesize_approx"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Extension string `json:"ext"`
OriginalURL string `json:"original_url"`
FileName string `json:"filename"`
CreatedAt time.Time `json:"created_at"`
}
// struct representing the response sent to the client // struct representing the response sent to the client
// as JSON-RPC result field // as JSON-RPC result field
type ProcessResponse struct { type ProcessResponse struct {
Id string `json:"id"` Id string `json:"id"`
Progress DownloadProgress `json:"progress"` Progress DownloadProgress `json:"progress"`
Info DownloadInfo `json:"info"` Info common.DownloadInfo `json:"info"`
Output DownloadOutput `json:"output"` Output DownloadOutput `json:"output"`
Params []string `json:"params"` Params []string `json:"params"`
} }

View File

@@ -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
} }

View File

@@ -9,20 +9,18 @@ import (
"strings" "strings"
"time" "time"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
"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/playlist"
) )
type metadata struct {
Entries []DownloadInfo `json:"entries"`
Count int `json:"playlist_count"`
PlaylistTitle string `json:"title"`
Type string `json:"_type"`
}
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error { func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
params := append(req.Params, "--flat-playlist", "-J")
urlWithParams := append([]string{req.URL}, params...)
var ( var (
downloader = config.Instance().DownloaderPath downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J") cmd = exec.Command(downloader, urlWithParams...)
) )
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
@@ -30,7 +28,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
return err return err
} }
var m metadata var m playlist.Metadata
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
@@ -53,12 +51,20 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
} }
if m.Type == "playlist" { if m.Type == "playlist" {
entries := slices.CompactFunc(slices.Compact(m.Entries), func(a DownloadInfo, b 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
}) })
entries = slices.DeleteFunc(entries, func(e common.DownloadInfo) bool {
return strings.Contains(e.URL, "list=")
})
slog.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries))) slog.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries)))
if err := playlist.ApplyModifiers(&entries, req.Params); err != nil {
return err
}
for i, meta := range entries { for i, meta := range entries {
// detect playlist title from metadata since each playlist entry will be // detect playlist title from metadata since each playlist entry will be
// treated as an individual download // treated as an individual download
@@ -82,11 +88,13 @@ 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
} }
proc := &Process{ proc := &Process{

View File

@@ -19,6 +19,7 @@ import (
"time" "time"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
) )
@@ -50,7 +51,7 @@ type Process struct {
Livestream bool Livestream bool
AutoRemove bool AutoRemove bool
Params []string Params []string
Info DownloadInfo Info common.DownloadInfo
Progress DownloadProgress Progress DownloadProgress
Output DownloadOutput Output DownloadOutput
proc *os.Process proc *os.Process
@@ -302,7 +303,7 @@ func (p *Process) GetFileName(o *DownloadOutput) error {
func (p *Process) SetPending() { func (p *Process) SetPending() {
// Since video's title isn't available yet, fill in with the URL. // Since video's title isn't available yet, fill in with the URL.
p.Info = DownloadInfo{ p.Info = common.DownloadInfo{
URL: p.Url, URL: p.Url,
Title: p.Url, Title: p.Url,
CreatedAt: time.Now(), CreatedAt: time.Now(),
@@ -334,7 +335,7 @@ func (p *Process) SetMetadata() error {
return err return err
} }
info := DownloadInfo{ info := common.DownloadInfo{
URL: p.Url, URL: p.Url,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }

View File

@@ -0,0 +1,86 @@
package playlist
import (
"slices"
"strconv"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
)
/*
Applicable modifiers
full | short | description
---------------------------------------------------------------------------------
--playlist-start NUMBER | -I NUMBER: | discard first N entries
--playlist-end NUMBER | -I :NUMBER | discard last N entries
--playlist-reverse | -I ::-1 | self explanatory
--max-downloads NUMBER | | stops after N completed downloads
*/
func ApplyModifiers(entries *[]common.DownloadInfo, args []string) error {
for i, modifier := range args {
switch modifier {
case "--playlist-start":
return playlistStart(i, modifier, args, entries)
case "--playlist-end":
return playlistEnd(i, modifier, args, entries)
case "--max-downloads":
return maxDownloads(i, modifier, args, entries)
case "--playlist-reverse":
slices.Reverse(*entries)
return nil
}
}
return nil
}
func playlistStart(i int, modifier string, args []string, entries *[]common.DownloadInfo) error {
if !guard(i, len(modifier)) {
return nil
}
n, err := strconv.Atoi(args[i+1])
if err != nil {
return err
}
*entries = (*entries)[n:]
return nil
}
func playlistEnd(i int, modifier string, args []string, entries *[]common.DownloadInfo) error {
if !guard(i, len(modifier)) {
return nil
}
n, err := strconv.Atoi(args[i+1])
if err != nil {
return err
}
*entries = (*entries)[:n]
return nil
}
func maxDownloads(i int, modifier string, args []string, entries *[]common.DownloadInfo) error {
if !guard(i, len(modifier)) {
return nil
}
n, err := strconv.Atoi(args[i+1])
if err != nil {
return err
}
*entries = (*entries)[0:n]
return nil
}
func guard(i, len int) bool { return i+1 < len-1 }

10
server/playlist/types.go Normal file
View File

@@ -0,0 +1,10 @@
package playlist
import "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common"
type Metadata struct {
Entries []common.DownloadInfo `json:"entries"`
Count int `json:"playlist_count"`
PlaylistTitle string `json:"title"`
Type string `json:"_type"`
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/configurator"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/dbutil" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/dbutil"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/filebrowser" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/filebrowser"
"github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal"
@@ -235,6 +236,9 @@ func newServer(c serverConfig) *http.Server {
// Subscriptions // Subscriptions
r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter()) r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter())
// Frontend config store
r.Route("/webconfig", configurator.ApplyRouter())
return &http.Server{Handler: r} return &http.Server{Handler: r}
} }

View File

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

View File

@@ -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)
} }

View File

@@ -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() {
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 {
select {
case <-req.Done:
slog.Info("stopping cron job and removing schedule", slog.String("url", req.Subscription.URL))
cancel()
return
case <-fetcherEvents:
slog.Info("finished monitoring channel", slog.String("url", req.Subscription.URL))
}
}
}()
}
}
// Stop a currently scheduled job
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() { go func() {
for { for {
slog.Info("fetching latest video for channel", slog.String("channel", task.Subscription.URL)) sleepFor := t.fetcher(ctx, req)
completed <- struct{}{}
fetcherParams := strings.Split(strings.Replace(commandTemplate, "$1", task.Subscription.URL, 1), " ") 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( cmd := exec.CommandContext(
ctx, ctx,
config.Instance().DownloaderPath, config.Instance().DownloaderPath,
fetcherParams..., "-I1",
"--flat-playlist",
"--print", "webpage_url",
req.Subscription.URL,
) )
stdout, err := cmd.Output() stdout, err := cmd.Output()
if err != nil { if err != nil {
t.errors <- err t.errors <- err
return return time.Duration(0)
} }
latestChannelURL := string(bytes.Trim(stdout, "\n")) 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{ p := &internal.Process{
Url: latestChannelURL, Url: latestVideoURL,
Params: append(argsSplitterRe.FindAllString(task.Subscription.Params, 1), []string{ Params: append(
argsSplitterRe.FindAllString(req.Subscription.Params, 1),
[]string{
"--break-on-existing",
"--download-archive", "--download-archive",
filepath.Join(config.Instance().Dir(), "archive.txt"), filepath.Join(config.Instance().Dir(), "archive.txt"),
}...), }...),
AutoRemove: true, AutoRemove: true,
} }
t.db.Set(p) t.db.Set(p) // give it an id
t.mq.Publish(p) t.mq.Publish(p) // send it to the message queue waiting to be processed
nextSchedule := time.Until(task.Schedule.Next(time.Now()))
slog.Info( slog.Info(
"cron task runner next schedule", "cron task runner next schedule",
slog.String("url", task.Subscription.URL), slog.String("url", req.Subscription.URL),
slog.Any("duration", nextSchedule), slog.Any("duration", nextSchedule),
) )
time.Sleep(nextSchedule) return nextSchedule
}
}()
}
} }
func (t *CronTaskRunner) Recoverer() { func (t *CronTaskRunner) Recoverer() {
panic("Unimplemented") panic("unimplemented")
} }