templates editor #62
This commit is contained in:
@@ -27,4 +27,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
||||
ENV JWT_SECRET=secret
|
||||
|
||||
EXPOSE 3033
|
||||
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml" ]
|
||||
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]
|
||||
|
||||
@@ -15,9 +15,9 @@ The bottleneck remains yt-dlp startup time.
|
||||
docker pull marcobaobao/yt-dlp-webui
|
||||
```
|
||||
```sh
|
||||
# latest stable
|
||||
# latest dev
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
languages:
|
||||
english:
|
||||
urlInput: YouTube or other supported service video URL
|
||||
urlInput: Video URL
|
||||
statusTitle: Status
|
||||
statusReady: Ready
|
||||
selectFormatButton: Select format
|
||||
@@ -36,6 +36,10 @@ languages:
|
||||
restartAppMessage: Needs a page reload to take effect
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: App title
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
french:
|
||||
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
||||
statusTitle: Statut
|
||||
@@ -72,8 +76,12 @@ languages:
|
||||
restartAppMessage: Nécessite un rechargement de la page pour prendre effet
|
||||
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
|
||||
appTitle: Nom de l'application
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
italian:
|
||||
urlInput: URL di YouTube o di qualsiasi altro servizio supportato
|
||||
urlInput: URL Video
|
||||
statusTitle: Stato
|
||||
startButton: Inizia
|
||||
statusReady: Pronto
|
||||
@@ -107,6 +115,10 @@ languages:
|
||||
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: Titolo applicazione
|
||||
savedTemplates: Template salvati
|
||||
templatesEditor: Editor template
|
||||
templatesEditorNameLabel: Nome template
|
||||
templatesEditorContentLabel: Contentunto template
|
||||
chinese:
|
||||
urlInput: YouTube 或其他受支持服务的视频网址
|
||||
statusTitle: 状态
|
||||
@@ -143,6 +155,10 @@ languages:
|
||||
restartAppMessage: 需要刷新页面才能生效
|
||||
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
|
||||
appTitle: App 标题
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
spanish:
|
||||
urlInput: URL de YouTube u otro servicio compatible
|
||||
statusTitle: Estado
|
||||
@@ -177,6 +193,10 @@ languages:
|
||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: App title
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
russian:
|
||||
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
||||
statusTitle: Статус
|
||||
@@ -211,6 +231,10 @@ languages:
|
||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: App title
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
korean:
|
||||
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
||||
statusTitle: 상태
|
||||
@@ -245,6 +269,10 @@ languages:
|
||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: App title
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
japanese:
|
||||
urlInput: YouTubeまたはサポート済み動画のURL
|
||||
statusTitle: 状態
|
||||
@@ -280,6 +308,10 @@ languages:
|
||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: App title
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
catalan:
|
||||
urlInput: URL de YouTube o d'un altre servei compatible
|
||||
statusTitle: Estat
|
||||
@@ -314,6 +346,10 @@ languages:
|
||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: App title
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
ukrainian:
|
||||
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
||||
statusTitle: Статус
|
||||
@@ -348,6 +384,10 @@ languages:
|
||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: App title
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
polish:
|
||||
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
||||
statusTitle: Status
|
||||
@@ -382,3 +422,7 @@ languages:
|
||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
appTitle: App title
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
|
||||
@@ -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({
|
||||
key: 'downloadTemplateState',
|
||||
default: localStorage.getItem('lastDownloadTemplate') ?? '',
|
||||
export const cookiesTemplateState = atom({
|
||||
key: 'cookiesTemplateState',
|
||||
default: localStorage.getItem('cookiesTemplate') ?? '',
|
||||
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: [
|
||||
({ 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
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { pipe } from 'fp-ts/lib/function'
|
||||
import { useMemo } from 'react'
|
||||
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
|
||||
import { downloadTemplateState } from '../atoms/downloadTemplate'
|
||||
import { cookiesTemplateState } from '../atoms/downloadTemplate'
|
||||
import { cookiesState, serverURL } from '../atoms/settings'
|
||||
import { useSubscription } from '../hooks/observable'
|
||||
import { useToast } from '../hooks/toast'
|
||||
@@ -70,7 +70,7 @@ const validateCookie = (cookie: string) => pipe(
|
||||
|
||||
const CookiesTextField: React.FC = () => {
|
||||
const serverAddr = useRecoilValue(serverURL)
|
||||
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState)
|
||||
const [, setCookies] = useRecoilState(cookiesTemplateState)
|
||||
const [savedCookies, setSavedCookies] = useRecoilState(cookiesState)
|
||||
|
||||
const { pushMessage } = useToast()
|
||||
@@ -124,22 +124,18 @@ const CookiesTextField: React.FC = () => {
|
||||
validateNetscapeCookies,
|
||||
O.fromPredicate(f => f === true),
|
||||
O.match(
|
||||
() => {
|
||||
if (customArgs.includes(flag)) {
|
||||
setCustomArgs(a => a.replace(flag, ''))
|
||||
}
|
||||
},
|
||||
() => setCookies(''),
|
||||
async () => {
|
||||
pipe(
|
||||
await submitCookies(cookies),
|
||||
E.match(
|
||||
(l) => pushMessage(`${l}`, 'error'),
|
||||
() => pushMessage(`Saved Netscape cookies`, 'success')
|
||||
() => {
|
||||
pushMessage(`Saved Netscape cookies`, 'success')
|
||||
setCookies(flag)
|
||||
}
|
||||
)
|
||||
)
|
||||
if (!customArgs.includes(flag)) {
|
||||
setCustomArgs(a => `${a} ${flag}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FileUpload } from '@mui/icons-material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import {
|
||||
Autocomplete,
|
||||
Backdrop,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
@@ -10,10 +12,7 @@ import {
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
TextField
|
||||
} from '@mui/material'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
useTransition
|
||||
} from 'react'
|
||||
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||
import { downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate'
|
||||
import { customArgsState, downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate'
|
||||
import { settingsState } from '../atoms/settings'
|
||||
import { availableDownloadPathsState, connectedState } from '../atoms/status'
|
||||
import FormatsGrid from '../components/FormatsGrid'
|
||||
@@ -39,6 +38,7 @@ import { useRPC } from '../hooks/useRPC'
|
||||
import { CliArguments } from '../lib/argsParser'
|
||||
import type { DLMetadata } from '../types'
|
||||
import { isValidURL, toFormatArgs } from '../utils'
|
||||
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
@@ -60,19 +60,18 @@ export default function DownloadDialog({
|
||||
onClose,
|
||||
onDownloadStart
|
||||
}: Props) {
|
||||
// recoil state
|
||||
const settings = useRecoilValue(settingsState)
|
||||
const isConnected = useRecoilValue(connectedState)
|
||||
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
|
||||
const downloadTemplate = useRecoilValue(downloadTemplateState)
|
||||
|
||||
// ephemeral state
|
||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||
|
||||
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState)
|
||||
const [downloadPath, setDownloadPath] = useState(0)
|
||||
const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
|
||||
const [downloadPath, setDownloadPath] = useState('')
|
||||
|
||||
const [filenameTemplate, setFilenameTemplate] = useRecoilState(
|
||||
filenameTemplateState
|
||||
@@ -83,20 +82,16 @@ export default function DownloadDialog({
|
||||
|
||||
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||
|
||||
// memos
|
||||
const cliArgs = useMemo(() =>
|
||||
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
|
||||
)
|
||||
|
||||
// context
|
||||
const { i18n } = useI18n()
|
||||
const { client } = useRPC()
|
||||
|
||||
// refs
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
const customFilenameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// transitions
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
/**
|
||||
@@ -108,13 +103,13 @@ export default function DownloadDialog({
|
||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
||||
|
||||
client.download(
|
||||
immediate || url || workingUrl,
|
||||
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
|
||||
availableDownloadPaths[downloadPath] ?? '',
|
||||
filenameTemplate,
|
||||
isPlaylist,
|
||||
)
|
||||
client.download({
|
||||
url: immediate || url || workingUrl,
|
||||
args: `${cliArgs.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||
pathOverride: downloadPath ?? '',
|
||||
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
||||
playlist: isPlaylist,
|
||||
})
|
||||
|
||||
setUrl('')
|
||||
setWorkingUrl('')
|
||||
@@ -177,36 +172,40 @@ export default function DownloadDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
Download
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container sx={{ my: 4 }}>
|
||||
<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">
|
||||
Download
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box sx={{
|
||||
backgroundColor: (theme) => theme.palette.background.default,
|
||||
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||
}}>
|
||||
<Container sx={{ my: 4 }} >
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
@@ -267,8 +266,9 @@ export default function DownloadDialog({
|
||||
}
|
||||
{
|
||||
settings.fileRenaming &&
|
||||
<Grid item xs={8}>
|
||||
<Grid item xs={settings.pathOverriding ? 8 : 12}>
|
||||
<TextField
|
||||
sx={{ mt: 1 }}
|
||||
ref={customFilenameInputRef}
|
||||
fullWidth
|
||||
label={i18n.t('customFilename')}
|
||||
@@ -286,22 +286,30 @@ export default function DownloadDialog({
|
||||
settings.pathOverriding &&
|
||||
<Grid item xs={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{i18n.t('customPath')}</InputLabel>
|
||||
<Select
|
||||
label={i18n.t('customPath')}
|
||||
defaultValue={0}
|
||||
variant={'outlined'}
|
||||
value={downloadPath}
|
||||
onChange={(e) => setDownloadPath(Number(e.target.value))}
|
||||
>
|
||||
{availableDownloadPaths.map((val: string, idx: number) => (
|
||||
<MenuItem key={idx} value={idx}>{val}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
options={availableDownloadPaths.map((dir) => ({ label: dir, dir }))}
|
||||
autoHighlight
|
||||
getOptionLabel={(option) => option.label}
|
||||
onChange={(_, value) => {
|
||||
setDownloadPath(value?.dir!)
|
||||
}}
|
||||
renderOption={(props, option) => (
|
||||
<Box
|
||||
component="li"
|
||||
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
|
||||
{...props}>
|
||||
{option.label}
|
||||
</Box>
|
||||
)}
|
||||
sx={{ width: '100%', mt: 1 }}
|
||||
renderInput={(params) => <TextField {...params} label={i18n.t('customPath')} />}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
}
|
||||
</Grid>
|
||||
<ExtraDownloadOptions />
|
||||
<Grid container spacing={1} pt={2} justifyContent="space-between">
|
||||
<Grid item>
|
||||
<Button
|
||||
@@ -357,7 +365,7 @@ export default function DownloadDialog({
|
||||
pickedAudioFormat={pickedAudioFormat}
|
||||
/>}
|
||||
</Container>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/ExtraDownloadOptions.tsx
Normal file
37
frontend/src/components/ExtraDownloadOptions.tsx
Normal 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
|
||||
@@ -4,30 +4,38 @@ import { loadingAtom } from '../atoms/ui'
|
||||
import DownloadDialog from './DownloadDialog'
|
||||
import HomeSpeedDial from './HomeSpeedDial'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import TemplatesEditor from './TemplatesEditor'
|
||||
|
||||
const HomeActions: React.FC = () => {
|
||||
const [, setIsLoading] = useRecoilState(loadingAtom)
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
|
||||
const [openDownload, setOpenDownload] = useState(false)
|
||||
const [openEditor, setOpenEditor] = useState(false)
|
||||
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
return (
|
||||
<>
|
||||
<HomeSpeedDial
|
||||
onOpen={() => setOpenDialog(true)}
|
||||
onDownloadOpen={() => setOpenDownload(true)}
|
||||
onEditorOpen={() => setOpenEditor(true)}
|
||||
/>
|
||||
<DownloadDialog
|
||||
open={openDialog}
|
||||
open={openDownload}
|
||||
onClose={() => {
|
||||
setOpenDialog(false)
|
||||
setOpenDownload(false)
|
||||
setIsLoading(true)
|
||||
}}
|
||||
onDownloadStart={(url) => {
|
||||
pushMessage(`Requested ${url}`, 'info')
|
||||
setOpenDialog(false)
|
||||
setOpenDownload(false)
|
||||
setIsLoading(true)
|
||||
}}
|
||||
/>
|
||||
<TemplatesEditor
|
||||
open={openEditor}
|
||||
onClose={() => setOpenEditor(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||
import {
|
||||
@@ -12,10 +13,11 @@ import { useI18n } from '../hooks/useI18n'
|
||||
import { useRPC } from '../hooks/useRPC'
|
||||
|
||||
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 { i18n } = useI18n()
|
||||
@@ -39,10 +41,15 @@ const HomeSpeedDial: React.FC<Props> = ({ onOpen }) => {
|
||||
tooltipTitle={i18n.t('abortAllButton')}
|
||||
onClick={abort}
|
||||
/>
|
||||
<SpeedDialAction
|
||||
icon={<BuildCircleIcon />}
|
||||
tooltipTitle={i18n.t('templatesEditor')}
|
||||
onClick={onEditorOpen}
|
||||
/>
|
||||
<SpeedDialAction
|
||||
icon={<AddCircleIcon />}
|
||||
tooltipTitle={`New download`}
|
||||
onClick={onOpen}
|
||||
tooltipTitle={i18n.t('newDownload')}
|
||||
onClick={onDownloadOpen}
|
||||
/>
|
||||
</SpeedDial>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as O from 'fp-ts/Option'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||
import { share, take, timer } from 'rxjs'
|
||||
import { downloadsState } from '../atoms/downloads'
|
||||
@@ -14,7 +14,7 @@ import { datetimeCompareFunc, isRPCResponse } from '../utils'
|
||||
interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
|
||||
|
||||
const SocketSubscriber: React.FC<Props> = ({ children }) => {
|
||||
const [, setIsConnected] = useRecoilState(connectedState)
|
||||
const [connected, setIsConnected] = useRecoilState(connectedState)
|
||||
const [, setDownloads] = useRecoilState(downloadsState)
|
||||
|
||||
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 (
|
||||
<>{children}</>
|
||||
|
||||
226
frontend/src/components/TemplatesEditor.tsx
Normal file
226
frontend/src/components/TemplatesEditor.tsx
Normal 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
|
||||
@@ -3,6 +3,14 @@ import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
|
||||
|
||||
import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
|
||||
|
||||
type DownloadRequestArgs = {
|
||||
url: string,
|
||||
args: string,
|
||||
pathOverride?: string,
|
||||
renameTo?: string,
|
||||
playlist?: boolean
|
||||
}
|
||||
|
||||
export class RPCClient {
|
||||
private seq: number
|
||||
private httpEndpoint: string
|
||||
@@ -33,6 +41,7 @@ export class RPCClient {
|
||||
return args
|
||||
.split(' ')
|
||||
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
private async sendHTTP<T>(req: RPCRequest) {
|
||||
@@ -48,34 +57,45 @@ export class RPCClient {
|
||||
return data
|
||||
}
|
||||
|
||||
public download(
|
||||
url: string,
|
||||
args: string,
|
||||
pathOverride = '',
|
||||
renameTo = '',
|
||||
playlist?: boolean
|
||||
) {
|
||||
if (!url) {
|
||||
public download(req: DownloadRequestArgs) {
|
||||
if (!req.url) {
|
||||
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({
|
||||
method: 'Service.ExecPlaylist',
|
||||
params: [{
|
||||
URL: url,
|
||||
Params: this.argsSanitizer(args),
|
||||
Path: pathOverride,
|
||||
Rename: renameTo,
|
||||
URL: req.url,
|
||||
Params: sanitizedArgs,
|
||||
Path: req.pathOverride,
|
||||
Rename: req.renameTo || rename,
|
||||
}]
|
||||
})
|
||||
}
|
||||
this.sendHTTP({
|
||||
method: 'Service.Exec',
|
||||
params: [{
|
||||
URL: url.split('?list').at(0)!,
|
||||
Params: this.argsSanitizer(args),
|
||||
Path: pathOverride,
|
||||
Rename: renameTo,
|
||||
URL: req.url.split('?list').at(0)!,
|
||||
Params: sanitizedArgs,
|
||||
Path: req.pathOverride,
|
||||
Rename: req.renameTo || rename,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ const Toaster: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (toasts.length > 0) {
|
||||
|
||||
const closer = setInterval(() => {
|
||||
setToasts(t => t.map(t => ({ ...t, open: deletePredicate(t) })))
|
||||
}, 2000)
|
||||
}, 900)
|
||||
|
||||
const cleaner = setInterval(() => {
|
||||
setToasts(t => t.filter(deletePredicate))
|
||||
}, 1000)
|
||||
}, 2005)
|
||||
|
||||
return () => {
|
||||
clearInterval(closer)
|
||||
|
||||
@@ -83,3 +83,8 @@ export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
|
||||
|
||||
export type PlayRequest = Pick<DirectoryEntry, 'path'>
|
||||
|
||||
export type CustomTemplate = {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
20
go.mod
20
go.mod
@@ -12,3 +12,23 @@ require (
|
||||
golang.org/x/sys v0.13.0
|
||||
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
54
go.sum
@@ -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/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
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/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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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
16
main.go
@@ -14,12 +14,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
queueSize int
|
||||
configFile string
|
||||
downloadPath string
|
||||
downloaderPath string
|
||||
sessionFilePath string
|
||||
port int
|
||||
queueSize int
|
||||
configFile string
|
||||
downloadPath string
|
||||
downloaderPath string
|
||||
sessionFilePath string
|
||||
localDatabasePath string
|
||||
|
||||
requireAuth bool
|
||||
username string
|
||||
@@ -42,6 +43,7 @@ func init() {
|
||||
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
|
||||
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable 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.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")
|
||||
}
|
||||
|
||||
server.RunBlocking(port, frontend)
|
||||
server.RunBlocking(port, frontend, localDatabasePath)
|
||||
}
|
||||
|
||||
26
server/dbutils/migrate.go
Normal file
26
server/dbutils/migrate.go
Normal 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
|
||||
}
|
||||
@@ -78,3 +78,9 @@ type DownloadRequest struct {
|
||||
type SetCookiesRequest struct {
|
||||
Cookies string `json:"cookies"`
|
||||
}
|
||||
|
||||
type CustomTemplate struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||
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 (
|
||||
service = ProvideService(db, mq)
|
||||
service = ProvideService(db, mdb, mq)
|
||||
handler = ProvideHandler(service)
|
||||
)
|
||||
return handler
|
||||
}
|
||||
|
||||
func ApplyRouter(db *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) {
|
||||
h := Container(db, mq)
|
||||
func ApplyRouter(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) {
|
||||
h := Container(db, mdb, mq)
|
||||
|
||||
return func(r chi.Router) {
|
||||
r.Use(middlewares.Authenticated)
|
||||
r.Post("/exec", h.Exec())
|
||||
r.Get("/running", h.Running())
|
||||
r.Post("/cookies", h.SetCookies())
|
||||
r.Post("/template", h.AddTemplate())
|
||||
r.Get("/template/all", h.GetTemplates())
|
||||
r.Delete("/template/{id}", h.DeleteTemplate())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"sync"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||
@@ -14,11 +15,12 @@ var (
|
||||
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() {
|
||||
service = &Service{
|
||||
db: db,
|
||||
mq: mq,
|
||||
mdb: mdb,
|
||||
db: db,
|
||||
mq: mq,
|
||||
}
|
||||
})
|
||||
return service
|
||||
|
||||
@@ -2,15 +2,18 @@ package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *internal.MemoryDB
|
||||
mq *internal.MessageQueue
|
||||
mdb *internal.MemoryDB
|
||||
db *sql.DB
|
||||
mq *internal.MessageQueue
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return id, nil
|
||||
@@ -34,7 +37,7 @@ func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, err
|
||||
case <-ctx.Done():
|
||||
return nil, errors.New("context cancelled")
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -15,23 +16,37 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"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/internal"
|
||||
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
||||
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type serverConfig struct {
|
||||
frontend fs.FS
|
||||
port int
|
||||
db *internal.MemoryDB
|
||||
mdb *internal.MemoryDB
|
||||
db *sql.DB
|
||||
mq *internal.MessageQueue
|
||||
}
|
||||
|
||||
func RunBlocking(port int, frontend fs.FS) {
|
||||
var db internal.MemoryDB
|
||||
db.Restore()
|
||||
func RunBlocking(port int, frontend fs.FS, dbPath string) {
|
||||
var mdb internal.MemoryDB
|
||||
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()
|
||||
go mq.Subscriber()
|
||||
@@ -39,18 +54,19 @@ func RunBlocking(port int, frontend fs.FS) {
|
||||
srv := newServer(serverConfig{
|
||||
frontend: frontend,
|
||||
port: port,
|
||||
db: &db,
|
||||
mdb: &mdb,
|
||||
mq: mq,
|
||||
db: db,
|
||||
})
|
||||
|
||||
go gracefulShutdown(srv, &db)
|
||||
go autoPersist(time.Minute*5, &db)
|
||||
go gracefulShutdown(srv, &mdb)
|
||||
go autoPersist(time.Minute*5, &mdb)
|
||||
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
|
||||
func newServer(c serverConfig) *http.Server {
|
||||
service := ytdlpRPC.Container(c.db, c.mq)
|
||||
service := ytdlpRPC.Container(c.mdb, c.mq)
|
||||
rpc.Register(service)
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -80,7 +96,7 @@ func newServer(c serverConfig) *http.Server {
|
||||
r.Route("/rpc", ytdlpRPC.ApplyRouter())
|
||||
|
||||
// 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{
|
||||
Addr: fmt.Sprintf(":%d", c.port),
|
||||
|
||||
Reference in New Issue
Block a user