templates editor #62

This commit is contained in:
2023-10-30 11:48:10 +01:00
parent cd5c210eac
commit 19f9b10844
24 changed files with 798 additions and 141 deletions

View File

@@ -27,4 +27,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret ENV JWT_SECRET=secret
EXPOSE 3033 EXPOSE 3033
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml" ] ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]

View File

@@ -15,9 +15,9 @@ The bottleneck remains yt-dlp startup time.
docker pull marcobaobao/yt-dlp-webui docker pull marcobaobao/yt-dlp-webui
``` ```
```sh ```sh
# latest stable # latest dev
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
# latest dev version # latest stable version
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
``` ```

View File

@@ -1,7 +1,7 @@
--- ---
languages: languages:
english: english:
urlInput: YouTube or other supported service video URL urlInput: Video URL
statusTitle: Status statusTitle: Status
statusReady: Ready statusReady: Ready
selectFormatButton: Select format selectFormatButton: Select format
@@ -36,6 +36,10 @@ languages:
restartAppMessage: Needs a page reload to take effect restartAppMessage: Needs a page reload to take effect
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
french: french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut statusTitle: Statut
@@ -72,8 +76,12 @@ languages:
restartAppMessage: Nécessite un rechargement de la page pour prendre effet restartAppMessage: Nécessite un rechargement de la page pour prendre effet
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
appTitle: Nom de l'application appTitle: Nom de l'application
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
italian: italian:
urlInput: URL di YouTube o di qualsiasi altro servizio supportato urlInput: URL Video
statusTitle: Stato statusTitle: Stato
startButton: Inizia startButton: Inizia
statusReady: Pronto statusReady: Pronto
@@ -107,6 +115,10 @@ languages:
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: Titolo applicazione appTitle: Titolo applicazione
savedTemplates: Template salvati
templatesEditor: Editor template
templatesEditorNameLabel: Nome template
templatesEditorContentLabel: Contentunto template
chinese: chinese:
urlInput: YouTube 或其他受支持服务的视频网址 urlInput: YouTube 或其他受支持服务的视频网址
statusTitle: 状态 statusTitle: 状态
@@ -143,6 +155,10 @@ languages:
restartAppMessage: 需要刷新页面才能生效 restartAppMessage: 需要刷新页面才能生效
servedFromReverseProxyCheckbox: 处于反向代理的子目录后 servedFromReverseProxyCheckbox: 处于反向代理的子目录后
appTitle: App 标题 appTitle: App 标题
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -177,6 +193,10 @@ languages:
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
russian: russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус statusTitle: Статус
@@ -211,6 +231,10 @@ languages:
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -245,6 +269,10 @@ languages:
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -280,6 +308,10 @@ languages:
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
catalan: catalan:
urlInput: URL de YouTube o d'un altre servei compatible urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat statusTitle: Estat
@@ -314,6 +346,10 @@ languages:
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
ukrainian: ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус statusTitle: Статус
@@ -348,6 +384,10 @@ languages:
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
polish: polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status statusTitle: Status
@@ -382,3 +422,7 @@ languages:
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
appTitle: App title appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content

View File

@@ -1,10 +1,23 @@
import { atom } from 'recoil' import { atom, selector } from 'recoil'
import { CustomTemplate } from '../types'
import { ffetch } from '../lib/httpClient'
import { serverURL } from './settings'
import { pipe } from 'fp-ts/lib/function'
import { getOrElse } from 'fp-ts/lib/Either'
export const downloadTemplateState = atom({ export const cookiesTemplateState = atom({
key: 'downloadTemplateState', key: 'cookiesTemplateState',
default: localStorage.getItem('lastDownloadTemplate') ?? '', default: localStorage.getItem('cookiesTemplate') ?? '',
effects: [ effects: [
({ onSet }) => onSet(e => localStorage.setItem('lastDownloadTemplate', e)) ({ onSet }) => onSet(e => localStorage.setItem('cookiesTemplate', e))
]
})
export const customArgsState = atom({
key: 'customArgsState',
default: localStorage.getItem('customArgs') ?? '',
effects: [
({ onSet }) => onSet(e => localStorage.setItem('customArgs', e))
] ]
}) })
@@ -14,4 +27,26 @@ export const filenameTemplateState = atom({
effects: [ effects: [
({ onSet }) => onSet(e => localStorage.setItem('lastFilenameTemplate', e)) ({ onSet }) => onSet(e => localStorage.setItem('lastFilenameTemplate', e))
] ]
})
export const downloadTemplateState = selector({
key: 'downloadTemplateState',
get: ({ get }) =>
`${get(customArgsState)} ${get(cookiesTemplateState)}`
.replace(/ +/g, ' ')
.trim()
})
export const savedTemplatesState = selector<CustomTemplate[]>({
key: 'savedTemplatesState',
get: async ({ get }) => {
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
const either = await task()
return pipe(
either,
getOrElse(() => new Array<CustomTemplate>())
)
},
dangerouslyAllowMutability: true
}) })

View File

@@ -6,7 +6,7 @@ import { pipe } from 'fp-ts/lib/function'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs' import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { downloadTemplateState } from '../atoms/downloadTemplate' import { cookiesTemplateState } from '../atoms/downloadTemplate'
import { cookiesState, serverURL } from '../atoms/settings' import { cookiesState, serverURL } from '../atoms/settings'
import { useSubscription } from '../hooks/observable' import { useSubscription } from '../hooks/observable'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
@@ -70,7 +70,7 @@ const validateCookie = (cookie: string) => pipe(
const CookiesTextField: React.FC = () => { const CookiesTextField: React.FC = () => {
const serverAddr = useRecoilValue(serverURL) const serverAddr = useRecoilValue(serverURL)
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState) const [, setCookies] = useRecoilState(cookiesTemplateState)
const [savedCookies, setSavedCookies] = useRecoilState(cookiesState) const [savedCookies, setSavedCookies] = useRecoilState(cookiesState)
const { pushMessage } = useToast() const { pushMessage } = useToast()
@@ -124,22 +124,18 @@ const CookiesTextField: React.FC = () => {
validateNetscapeCookies, validateNetscapeCookies,
O.fromPredicate(f => f === true), O.fromPredicate(f => f === true),
O.match( O.match(
() => { () => setCookies(''),
if (customArgs.includes(flag)) {
setCustomArgs(a => a.replace(flag, ''))
}
},
async () => { async () => {
pipe( pipe(
await submitCookies(cookies), await submitCookies(cookies),
E.match( E.match(
(l) => pushMessage(`${l}`, 'error'), (l) => pushMessage(`${l}`, 'error'),
() => pushMessage(`Saved Netscape cookies`, 'success') () => {
pushMessage(`Saved Netscape cookies`, 'success')
setCookies(flag)
}
) )
) )
if (!customArgs.includes(flag)) {
setCustomArgs(a => `${a} ${flag}`)
}
} }
) )
) )

View File

@@ -1,7 +1,9 @@
import { FileUpload } from '@mui/icons-material' import { FileUpload } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import { import {
Autocomplete,
Backdrop, Backdrop,
Box,
Button, Button,
Checkbox, Checkbox,
Container, Container,
@@ -10,10 +12,7 @@ import {
Grid, Grid,
IconButton, IconButton,
InputAdornment, InputAdornment,
InputLabel,
MenuItem,
Paper, Paper,
Select,
TextField TextField
} from '@mui/material' } from '@mui/material'
import AppBar from '@mui/material/AppBar' import AppBar from '@mui/material/AppBar'
@@ -30,7 +29,7 @@ import {
useTransition useTransition
} from 'react' } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate' import { customArgsState, downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate'
import { settingsState } from '../atoms/settings' import { settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status' import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
@@ -39,6 +38,7 @@ import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser' import { CliArguments } from '../lib/argsParser'
import type { DLMetadata } from '../types' import type { DLMetadata } from '../types'
import { isValidURL, toFormatArgs } from '../utils' import { isValidURL, toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions'
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
@@ -60,19 +60,18 @@ export default function DownloadDialog({
onClose, onClose,
onDownloadStart onDownloadStart
}: Props) { }: Props) {
// recoil state
const settings = useRecoilValue(settingsState) const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState) const isConnected = useRecoilValue(connectedState)
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState) const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
const downloadTemplate = useRecoilValue(downloadTemplateState)
// ephemeral state
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>() const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState('') const [pickedVideoFormat, setPickedVideoFormat] = useState('')
const [pickedAudioFormat, setPickedAudioFormat] = useState('') const [pickedAudioFormat, setPickedAudioFormat] = useState('')
const [pickedBestFormat, setPickedBestFormat] = useState('') const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState) const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [downloadPath, setDownloadPath] = useState(0) const [downloadPath, setDownloadPath] = useState('')
const [filenameTemplate, setFilenameTemplate] = useRecoilState( const [filenameTemplate, setFilenameTemplate] = useRecoilState(
filenameTemplateState filenameTemplateState
@@ -83,20 +82,16 @@ export default function DownloadDialog({
const [isPlaylist, setIsPlaylist] = useState(false) const [isPlaylist, setIsPlaylist] = useState(false)
// memos
const cliArgs = useMemo(() => const cliArgs = useMemo(() =>
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs] new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
) )
// context
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
// refs
const urlInputRef = useRef<HTMLInputElement>(null) const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null) const customFilenameInputRef = useRef<HTMLInputElement>(null)
// transitions
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
/** /**
@@ -108,13 +103,13 @@ export default function DownloadDialog({
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat) if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat) if (pickedBestFormat !== '') codes.push(pickedBestFormat)
client.download( client.download({
immediate || url || workingUrl, url: immediate || url || workingUrl,
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`, args: `${cliArgs.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
availableDownloadPaths[downloadPath] ?? '', pathOverride: downloadPath ?? '',
filenameTemplate, renameTo: settings.fileRenaming ? filenameTemplate : '',
isPlaylist, playlist: isPlaylist,
) })
setUrl('') setUrl('')
setWorkingUrl('') setWorkingUrl('')
@@ -177,36 +172,40 @@ export default function DownloadDialog({
} }
return ( return (
<div> <Dialog
<Dialog fullScreen
fullScreen open={open}
open={open} onClose={onClose}
onClose={onClose} TransitionComponent={Transition}
TransitionComponent={Transition} >
> <Backdrop
<Backdrop sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }} open={isPending}
open={isPending} />
/> <AppBar sx={{ position: 'relative' }}>
<AppBar sx={{ position: 'relative' }}> <Toolbar>
<Toolbar> <IconButton
<IconButton edge="start"
edge="start" color="inherit"
color="inherit" onClick={onClose}
onClick={onClose} aria-label="close"
aria-label="close" >
> <CloseIcon />
<CloseIcon /> </IconButton>
</IconButton> <Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div"> Download
Download </Typography>
</Typography> </Toolbar>
</Toolbar> </AppBar>
</AppBar> <Box sx={{
<Container sx={{ my: 4 }}> backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Container sx={{ my: 4 }} >
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12}> <Grid item xs={12}>
<Paper <Paper
elevation={4}
sx={{ sx={{
p: 2, p: 2,
display: 'flex', display: 'flex',
@@ -267,8 +266,9 @@ export default function DownloadDialog({
} }
{ {
settings.fileRenaming && settings.fileRenaming &&
<Grid item xs={8}> <Grid item xs={settings.pathOverriding ? 8 : 12}>
<TextField <TextField
sx={{ mt: 1 }}
ref={customFilenameInputRef} ref={customFilenameInputRef}
fullWidth fullWidth
label={i18n.t('customFilename')} label={i18n.t('customFilename')}
@@ -286,22 +286,30 @@ export default function DownloadDialog({
settings.pathOverriding && settings.pathOverriding &&
<Grid item xs={4}> <Grid item xs={4}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('customPath')}</InputLabel> <Autocomplete
<Select disablePortal
label={i18n.t('customPath')} options={availableDownloadPaths.map((dir) => ({ label: dir, dir }))}
defaultValue={0} autoHighlight
variant={'outlined'} getOptionLabel={(option) => option.label}
value={downloadPath} onChange={(_, value) => {
onChange={(e) => setDownloadPath(Number(e.target.value))} setDownloadPath(value?.dir!)
> }}
{availableDownloadPaths.map((val: string, idx: number) => ( renderOption={(props, option) => (
<MenuItem key={idx} value={idx}>{val}</MenuItem> <Box
))} component="li"
</Select> sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...props}>
{option.label}
</Box>
)}
sx={{ width: '100%', mt: 1 }}
renderInput={(params) => <TextField {...params} label={i18n.t('customPath')} />}
/>
</FormControl> </FormControl>
</Grid> </Grid>
} }
</Grid> </Grid>
<ExtraDownloadOptions />
<Grid container spacing={1} pt={2} justifyContent="space-between"> <Grid container spacing={1} pt={2} justifyContent="space-between">
<Grid item> <Grid item>
<Button <Button
@@ -357,7 +365,7 @@ export default function DownloadDialog({
pickedAudioFormat={pickedAudioFormat} pickedAudioFormat={pickedAudioFormat}
/>} />}
</Container> </Container>
</Dialog> </Box>
</div> </Dialog>
) )
} }

View File

@@ -0,0 +1,37 @@
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
import { useI18n } from '../hooks/useI18n'
const ExtraDownloadOptions: React.FC = () => {
const { i18n } = useI18n()
const customTemplates = useRecoilValue(savedTemplatesState)
const [, setCustomArgs] = useRecoilState(customArgsState)
return (
<>
<Autocomplete
disablePortal
options={customTemplates.map(({ name, content }) => ({ label: name, content }))}
autoHighlight
getOptionLabel={(option) => option.label}
onChange={(_, value) => {
setCustomArgs(value?.content!)
}}
renderOption={(props, option) => (
<Box
component="li"
sx={{ mr: 2, flexShrink: 0 }}
{...props}>
{option.label}
</Box>
)}
sx={{ width: '100%', mt: 2 }}
renderInput={(params) => <TextField {...params} label={i18n.t('savedTemplates')} />}
/>
</>
)
}
export default ExtraDownloadOptions

View File

@@ -4,30 +4,38 @@ import { loadingAtom } from '../atoms/ui'
import DownloadDialog from './DownloadDialog' import DownloadDialog from './DownloadDialog'
import HomeSpeedDial from './HomeSpeedDial' import HomeSpeedDial from './HomeSpeedDial'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import TemplatesEditor from './TemplatesEditor'
const HomeActions: React.FC = () => { const HomeActions: React.FC = () => {
const [, setIsLoading] = useRecoilState(loadingAtom) const [, setIsLoading] = useRecoilState(loadingAtom)
const [openDialog, setOpenDialog] = useState(false)
const [openDownload, setOpenDownload] = useState(false)
const [openEditor, setOpenEditor] = useState(false)
const { pushMessage } = useToast() const { pushMessage } = useToast()
return ( return (
<> <>
<HomeSpeedDial <HomeSpeedDial
onOpen={() => setOpenDialog(true)} onDownloadOpen={() => setOpenDownload(true)}
onEditorOpen={() => setOpenEditor(true)}
/> />
<DownloadDialog <DownloadDialog
open={openDialog} open={openDownload}
onClose={() => { onClose={() => {
setOpenDialog(false) setOpenDownload(false)
setIsLoading(true) setIsLoading(true)
}} }}
onDownloadStart={(url) => { onDownloadStart={(url) => {
pushMessage(`Requested ${url}`, 'info') pushMessage(`Requested ${url}`, 'info')
setOpenDialog(false) setOpenDownload(false)
setIsLoading(true) setIsLoading(true)
}} }}
/> />
<TemplatesEditor
open={openEditor}
onClose={() => setOpenEditor(false)}
/>
</> </>
) )
} }

View File

@@ -1,4 +1,5 @@
import AddCircleIcon from '@mui/icons-material/AddCircle' import AddCircleIcon from '@mui/icons-material/AddCircle'
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever' import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import { import {
@@ -12,10 +13,11 @@ import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
type Props = { type Props = {
onOpen: () => void onDownloadOpen: () => void
onEditorOpen: () => void
} }
const HomeSpeedDial: React.FC<Props> = ({ onOpen }) => { const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const [, setListView] = useRecoilState(listViewState) const [, setListView] = useRecoilState(listViewState)
const { i18n } = useI18n() const { i18n } = useI18n()
@@ -39,10 +41,15 @@ const HomeSpeedDial: React.FC<Props> = ({ onOpen }) => {
tooltipTitle={i18n.t('abortAllButton')} tooltipTitle={i18n.t('abortAllButton')}
onClick={abort} onClick={abort}
/> />
<SpeedDialAction
icon={<BuildCircleIcon />}
tooltipTitle={i18n.t('templatesEditor')}
onClick={onEditorOpen}
/>
<SpeedDialAction <SpeedDialAction
icon={<AddCircleIcon />} icon={<AddCircleIcon />}
tooltipTitle={`New download`} tooltipTitle={i18n.t('newDownload')}
onClick={onOpen} onClick={onDownloadOpen}
/> />
</SpeedDial> </SpeedDial>
) )

View File

@@ -1,5 +1,5 @@
import * as O from 'fp-ts/Option' import * as O from 'fp-ts/Option'
import { useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { share, take, timer } from 'rxjs' import { share, take, timer } from 'rxjs'
import { downloadsState } from '../atoms/downloads' import { downloadsState } from '../atoms/downloads'
@@ -14,7 +14,7 @@ import { datetimeCompareFunc, isRPCResponse } from '../utils'
interface Props extends React.HTMLAttributes<HTMLBaseElement> { } interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
const SocketSubscriber: React.FC<Props> = ({ children }) => { const SocketSubscriber: React.FC<Props> = ({ children }) => {
const [, setIsConnected] = useRecoilState(connectedState) const [connected, setIsConnected] = useRecoilState(connectedState)
const [, setDownloads] = useRecoilState(downloadsState) const [, setDownloads] = useRecoilState(downloadsState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState) const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
@@ -62,7 +62,11 @@ const SocketSubscriber: React.FC<Props> = ({ children }) => {
} }
) )
useSubscription(timer(0, 1000), () => client.running()) useEffect(() => {
if (connected) {
timer(0, 1000).subscribe(() => client.running())
}
}, [connected])
return ( return (
<>{children}</> <>{children}</>

View File

@@ -0,0 +1,226 @@
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
import DeleteIcon from '@mui/icons-material/Delete'
import {
AppBar,
Backdrop,
Box,
Button,
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 { forwardRef, useEffect, useState, useTransition } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient'
import { CustomTemplate } from '../types'
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />
})
interface Props extends React.HTMLAttributes<HTMLBaseElement> {
open: boolean
onClose: () => void
}
const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
const [templateName, setTemplateName] = useState('')
const [templateContent, setTemplateContent] = useState('')
const serverAddr = useRecoilValue(serverURL)
const [isPending, startTransition] = useTransition()
const [templates, setTemplates] = useState<CustomTemplate[]>([])
const { i18n } = useI18n()
const { pushMessage } = useToast()
useEffect(() => {
if (open) {
getTemplates()
}
}, [open])
const getTemplates = async () => {
const task = ffetch<CustomTemplate[]>(`${serverAddr}/api/v1/template/all`)
const either = await task()
pipe(
either,
matchW(
(l) => pushMessage(l),
(r) => setTemplates(r)
)
)
}
const addTemplate = async () => {
const task = ffetch<unknown>(`${serverAddr}/api/v1/template`, {
method: 'POST',
body: JSON.stringify({
name: templateName,
content: templateContent,
})
})
const either = await task()
pipe(
either,
matchW(
(l) => pushMessage(l, 'warning'),
() => {
pushMessage('Added template')
getTemplates()
setTemplateName('')
setTemplateContent('')
}
)
)
}
const deleteTemplate = async (id: string) => {
const task = ffetch<unknown>(`${serverAddr}/api/v1/template/${id}`, {
method: 'DELETE',
})
const either = await task()
pipe(
either,
matchW(
(l) => pushMessage(l, 'warning'),
() => {
pushMessage('Deleted template')
getTemplates()
}
)
)
}
return (
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isPending}
/>
<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('templatesEditor')}
</Typography>
</Toolbar>
</AppBar>
<Box sx={{
backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Grid container spacing={2} sx={{ p: 4 }}>
<Grid item xs={12}>
<Paper
elevation={4}
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container spacing={2} justifyContent="center" alignItems="center">
<Grid item xs={3}>
<TextField
fullWidth
label={i18n.t('templatesEditorNameLabel')}
onChange={e => setTemplateName(e.currentTarget.value)}
value={templateName}
/>
</Grid>
<Grid item xs={9}>
<TextField
fullWidth
label={i18n.t('templatesEditorContentLabel')}
onChange={e => setTemplateContent(e.currentTarget.value)}
value={templateContent}
InputProps={{
endAdornment: <Button
variant='contained'
onClick={() => startTransition(() => { addTemplate() })}
>
<AddIcon />
</Button>
}}
/>
</Grid>
</Grid>
{templates.map(template => (
<Grid
container
spacing={2}
justifyContent="center"
alignItems="center"
key={template.id}
sx={{ mt: 1 }}
>
<Grid item xs={3}>
<TextField
fullWidth
label={i18n.t('templatesEditorNameLabel')}
value={template.name}
/>
</Grid>
<Grid item xs={9}>
<TextField
fullWidth
label={i18n.t('templatesEditorContentLabel')}
value={template.content}
InputProps={{
endAdornment: <Button
variant='contained'
onClick={() => {
startTransition(() => { deleteTemplate(template.id) })
}}>
<DeleteIcon />
</Button>
}}
/>
</Grid>
</Grid>
))}
</Paper>
</Grid>
</Grid>
</Box>
</Dialog >
)
}
export default TemplatesEditor

View File

@@ -3,6 +3,14 @@ import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
import { WebSocketSubject, webSocket } from 'rxjs/webSocket' import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
type DownloadRequestArgs = {
url: string,
args: string,
pathOverride?: string,
renameTo?: string,
playlist?: boolean
}
export class RPCClient { export class RPCClient {
private seq: number private seq: number
private httpEndpoint: string private httpEndpoint: string
@@ -33,6 +41,7 @@ export class RPCClient {
return args return args
.split(' ') .split(' ')
.map(a => a.trim().replaceAll("'", '').replaceAll('"', '')) .map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
.filter(Boolean)
} }
private async sendHTTP<T>(req: RPCRequest) { private async sendHTTP<T>(req: RPCRequest) {
@@ -48,34 +57,45 @@ export class RPCClient {
return data return data
} }
public download( public download(req: DownloadRequestArgs) {
url: string, if (!req.url) {
args: string,
pathOverride = '',
renameTo = '',
playlist?: boolean
) {
if (!url) {
return return
} }
if (playlist) {
const rename = req.args.includes('-o')
? req.args
.substring(req.args.indexOf('-o'))
.replaceAll("'", '')
.replaceAll('"', '')
.split('-o')
.map(s => s.trim())
.join('')
.split(' ')
.at(0) ?? ''
: ''
const sanitizedArgs = this.argsSanitizer(
req.args.replace('-o', '').replace(rename, '')
)
if (req.playlist) {
return this.sendHTTP({ return this.sendHTTP({
method: 'Service.ExecPlaylist', method: 'Service.ExecPlaylist',
params: [{ params: [{
URL: url, URL: req.url,
Params: this.argsSanitizer(args), Params: sanitizedArgs,
Path: pathOverride, Path: req.pathOverride,
Rename: renameTo, Rename: req.renameTo || rename,
}] }]
}) })
} }
this.sendHTTP({ this.sendHTTP({
method: 'Service.Exec', method: 'Service.Exec',
params: [{ params: [{
URL: url.split('?list').at(0)!, URL: req.url.split('?list').at(0)!,
Params: this.argsSanitizer(args), Params: sanitizedArgs,
Path: pathOverride, Path: req.pathOverride,
Rename: renameTo, Rename: req.renameTo || rename,
}] }]
}) })
} }

View File

@@ -10,14 +10,13 @@ const Toaster: React.FC = () => {
useEffect(() => { useEffect(() => {
if (toasts.length > 0) { if (toasts.length > 0) {
const closer = setInterval(() => { const closer = setInterval(() => {
setToasts(t => t.map(t => ({ ...t, open: deletePredicate(t) }))) setToasts(t => t.map(t => ({ ...t, open: deletePredicate(t) })))
}, 2000) }, 900)
const cleaner = setInterval(() => { const cleaner = setInterval(() => {
setToasts(t => t.filter(deletePredicate)) setToasts(t => t.filter(deletePredicate))
}, 1000) }, 2005)
return () => { return () => {
clearInterval(closer) clearInterval(closer)

View File

@@ -83,3 +83,8 @@ export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
export type PlayRequest = Pick<DirectoryEntry, 'path'> export type PlayRequest = Pick<DirectoryEntry, 'path'>
export type CustomTemplate = {
id: string
name: string
content: string
}

20
go.mod
View File

@@ -12,3 +12,23 @@ require (
golang.org/x/sys v0.13.0 golang.org/x/sys v0.13.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/mod v0.3.0 // indirect
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.6.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.26.0 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)

54
go.sum
View File

@@ -1,3 +1,5 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
@@ -8,11 +10,63 @@ github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

16
main.go
View File

@@ -14,12 +14,13 @@ import (
) )
var ( var (
port int port int
queueSize int queueSize int
configFile string configFile string
downloadPath string downloadPath string
downloaderPath string downloaderPath string
sessionFilePath string sessionFilePath string
localDatabasePath string
requireAuth bool requireAuth bool
username string username string
@@ -42,6 +43,7 @@ func init() {
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path") flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.StringVar(&sessionFilePath, "session", ".", "session file path") flag.StringVar(&sessionFilePath, "session", ".", "session file path")
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication") flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
flag.StringVar(&username, "user", userFromEnv, "Username required for auth") flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
@@ -74,5 +76,5 @@ func main() {
log.Println(cli.BgRed, "config", cli.Reset, "no config file found") log.Println(cli.BgRed, "config", cli.Reset, "no config file found")
} }
server.RunBlocking(port, frontend) server.RunBlocking(port, frontend, localDatabasePath)
} }

26
server/dbutils/migrate.go Normal file
View File

@@ -0,0 +1,26 @@
package dbutils
import (
"context"
"database/sql"
)
func AutoMigrate(ctx context.Context, db *sql.DB) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = db.ExecContext(
ctx,
`CREATE TABLE IF NOT EXISTS templates (
id CHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content TEXT NOT NULL
)`,
)
return err
}

View File

@@ -78,3 +78,9 @@ type DownloadRequest struct {
type SetCookiesRequest struct { type SetCookiesRequest struct {
Cookies string `json:"cookies"` Cookies string `json:"cookies"`
} }
type CustomTemplate struct {
Id string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
}

View File

@@ -1,26 +1,31 @@
package rest package rest
import ( import (
"database/sql"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
) )
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Handler { func Container(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) *Handler {
var ( var (
service = ProvideService(db, mq) service = ProvideService(db, mdb, mq)
handler = ProvideHandler(service) handler = ProvideHandler(service)
) )
return handler return handler
} }
func ApplyRouter(db *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) { func ApplyRouter(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) {
h := Container(db, mq) h := Container(db, mdb, mq)
return func(r chi.Router) { return func(r chi.Router) {
r.Use(middlewares.Authenticated) r.Use(middlewares.Authenticated)
r.Post("/exec", h.Exec()) r.Post("/exec", h.Exec())
r.Get("/running", h.Running()) r.Get("/running", h.Running())
r.Post("/cookies", h.SetCookies()) r.Post("/cookies", h.SetCookies())
r.Post("/template", h.AddTemplate())
r.Get("/template/all", h.GetTemplates())
r.Delete("/template/{id}", h.DeleteTemplate())
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
@@ -81,3 +82,75 @@ func (h *Handler) SetCookies() http.HandlerFunc {
} }
} }
} }
func (h *Handler) AddTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
req := new(internal.CustomTemplate)
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Name == "" || req.Content == "" {
http.Error(w, "Invalid template", http.StatusBadRequest)
return
}
err = h.service.SaveTemplate(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) GetTemplates() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
templates, err := h.service.GetTemplates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(templates)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) DeleteTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
id := chi.URLParam(r, "id")
err := h.service.DeleteTemplate(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

View File

@@ -1,6 +1,7 @@
package rest package rest
import ( import (
"database/sql"
"sync" "sync"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
@@ -14,11 +15,12 @@ var (
handlerOnce sync.Once handlerOnce sync.Once
) )
func ProvideService(db *internal.MemoryDB, mq *internal.MessageQueue) *Service { func ProvideService(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) *Service {
serviceOnce.Do(func() { serviceOnce.Do(func() {
service = &Service{ service = &Service{
db: db, mdb: mdb,
mq: mq, db: db,
mq: mq,
} }
}) })
return service return service

View File

@@ -2,15 +2,18 @@ package rest
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"os" "os"
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
type Service struct { type Service struct {
db *internal.MemoryDB mdb *internal.MemoryDB
mq *internal.MessageQueue db *sql.DB
mq *internal.MessageQueue
} }
func (s *Service) Exec(req internal.DownloadRequest) (string, error) { func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
@@ -23,7 +26,7 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
}, },
} }
id := s.db.Set(p) id := s.mdb.Set(p)
s.mq.Publish(p) s.mq.Publish(p)
return id, nil return id, nil
@@ -34,7 +37,7 @@ func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, err
case <-ctx.Done(): case <-ctx.Done():
return nil, errors.New("context cancelled") return nil, errors.New("context cancelled")
default: default:
return s.db.All(), nil return s.mdb.All(), nil
} }
} }
@@ -49,3 +52,64 @@ func (s *Service) SetCookies(ctx context.Context, cookies string) error {
return nil return nil
} }
func (s *Service) SaveTemplate(ctx context.Context, template *internal.CustomTemplate) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(
ctx,
"INSERT INTO templates (id, name, content) VALUES (?, ?, ?)",
uuid.NewString(),
template.Name,
template.Content,
)
return err
}
func (s *Service) GetTemplates(ctx context.Context) (*[]internal.CustomTemplate, error) {
conn, err := s.db.Conn(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
rows, err := conn.QueryContext(ctx, "SELECT * FROM templates")
if err != nil {
return nil, err
}
templates := make([]internal.CustomTemplate, 0)
for rows.Next() {
t := internal.CustomTemplate{}
err := rows.Scan(&t.Id, &t.Name, &t.Content)
if err != nil {
return nil, err
}
templates = append(templates, t)
}
return &templates, nil
}
func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
conn, err := s.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(ctx, "DELETE FROM templates WHERE id = ?", id)
return err
}

View File

@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"io/fs" "io/fs"
"log" "log"
@@ -15,23 +16,37 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/marcopeocchi/yt-dlp-web-ui/server/dbutils"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers" "github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest" "github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc" ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
_ "modernc.org/sqlite"
) )
type serverConfig struct { type serverConfig struct {
frontend fs.FS frontend fs.FS
port int port int
db *internal.MemoryDB mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue mq *internal.MessageQueue
} }
func RunBlocking(port int, frontend fs.FS) { func RunBlocking(port int, frontend fs.FS, dbPath string) {
var db internal.MemoryDB var mdb internal.MemoryDB
db.Restore() mdb.Restore()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatalln(err)
}
err = dbutils.AutoMigrate(context.Background(), db)
if err != nil {
log.Fatalln(err)
}
mq := internal.NewMessageQueue() mq := internal.NewMessageQueue()
go mq.Subscriber() go mq.Subscriber()
@@ -39,18 +54,19 @@ func RunBlocking(port int, frontend fs.FS) {
srv := newServer(serverConfig{ srv := newServer(serverConfig{
frontend: frontend, frontend: frontend,
port: port, port: port,
db: &db, mdb: &mdb,
mq: mq, mq: mq,
db: db,
}) })
go gracefulShutdown(srv, &db) go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &db) go autoPersist(time.Minute*5, &mdb)
log.Fatal(srv.ListenAndServe()) log.Fatal(srv.ListenAndServe())
} }
func newServer(c serverConfig) *http.Server { func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.db, c.mq) service := ytdlpRPC.Container(c.mdb, c.mq)
rpc.Register(service) rpc.Register(service)
r := chi.NewRouter() r := chi.NewRouter()
@@ -80,7 +96,7 @@ func newServer(c serverConfig) *http.Server {
r.Route("/rpc", ytdlpRPC.ApplyRouter()) r.Route("/rpc", ytdlpRPC.ApplyRouter())
// REST API handlers // REST API handlers
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mq)) r.Route("/api/v1", rest.ApplyRouter(c.db, c.mdb, c.mq))
return &http.Server{ return &http.Server{
Addr: fmt.Sprintf(":%d", c.port), Addr: fmt.Sprintf(":%d", c.port),