diff --git a/Dockerfile b/Dockerfile index 41fe513..6b6c86b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" ] diff --git a/README.md b/README.md index cabf479..b5acedf 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/frontend/src/assets/i18n.yaml b/frontend/src/assets/i18n.yaml index 4279657..dd9659a 100644 --- a/frontend/src/assets/i18n.yaml +++ b/frontend/src/assets/i18n.yaml @@ -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 diff --git a/frontend/src/atoms/downloadTemplate.ts b/frontend/src/atoms/downloadTemplate.ts index f6a421e..5f35011 100644 --- a/frontend/src/atoms/downloadTemplate.ts +++ b/frontend/src/atoms/downloadTemplate.ts @@ -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('cookiesTemplate', e)) + ] +}) + +export const customArgsState = atom({ + key: 'customArgsState', + default: localStorage.getItem('customArgs') ?? '', effects: [ - ({ onSet }) => onSet(e => localStorage.setItem('lastDownloadTemplate', e)) + ({ 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({ + key: 'savedTemplatesState', + get: async ({ get }) => { + const task = ffetch(`${get(serverURL)}/api/v1/template/all`) + const either = await task() + + return pipe( + either, + getOrElse(() => new Array()) + ) + }, + dangerouslyAllowMutability: true }) \ No newline at end of file diff --git a/frontend/src/components/CookiesTextField.tsx b/frontend/src/components/CookiesTextField.tsx index 1864a77..9f0ee9a 100644 --- a/frontend/src/components/CookiesTextField.tsx +++ b/frontend/src/components/CookiesTextField.tsx @@ -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}`) - } } ) ) diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 0259746..766d02c 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -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() 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(null) const customFilenameInputRef = useRef(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 ( -
- - theme.zIndex.drawer + 1 }} - open={isPending} - /> - - - - - - - Download - - - - + + theme.zIndex.drawer + 1 }} + open={isPending} + /> + + + + + + + Download + + + + theme.palette.background.default, + minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)` + }}> + + - {i18n.t('customPath')} - + ({ label: dir, dir }))} + autoHighlight + getOptionLabel={(option) => option.label} + onChange={(_, value) => { + setDownloadPath(value?.dir!) + }} + renderOption={(props, option) => ( + img': { mr: 2, flexShrink: 0 } }} + {...props}> + {option.label} + + )} + sx={{ width: '100%', mt: 1 }} + renderInput={(params) => } + /> } + -
+ + ) } \ No newline at end of file diff --git a/frontend/src/components/ExtraDownloadOptions.tsx b/frontend/src/components/ExtraDownloadOptions.tsx new file mode 100644 index 0000000..bc33ad2 --- /dev/null +++ b/frontend/src/components/ExtraDownloadOptions.tsx @@ -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 ( + <> + ({ label: name, content }))} + autoHighlight + getOptionLabel={(option) => option.label} + onChange={(_, value) => { + setCustomArgs(value?.content!) + }} + renderOption={(props, option) => ( + + {option.label} + + )} + sx={{ width: '100%', mt: 2 }} + renderInput={(params) => } + /> + + ) +} + +export default ExtraDownloadOptions \ No newline at end of file diff --git a/frontend/src/components/HomeActions.tsx b/frontend/src/components/HomeActions.tsx index 5978572..22d6414 100644 --- a/frontend/src/components/HomeActions.tsx +++ b/frontend/src/components/HomeActions.tsx @@ -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 ( <> setOpenDialog(true)} + onDownloadOpen={() => setOpenDownload(true)} + onEditorOpen={() => setOpenEditor(true)} /> { - setOpenDialog(false) + setOpenDownload(false) setIsLoading(true) }} onDownloadStart={(url) => { pushMessage(`Requested ${url}`, 'info') - setOpenDialog(false) + setOpenDownload(false) setIsLoading(true) }} /> + setOpenEditor(false)} + /> ) } diff --git a/frontend/src/components/HomeSpeedDial.tsx b/frontend/src/components/HomeSpeedDial.tsx index 8e44337..0ff553a 100644 --- a/frontend/src/components/HomeSpeedDial.tsx +++ b/frontend/src/components/HomeSpeedDial.tsx @@ -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 = ({ onOpen }) => { +const HomeSpeedDial: React.FC = ({ onDownloadOpen, onEditorOpen }) => { const [, setListView] = useRecoilState(listViewState) const { i18n } = useI18n() @@ -39,10 +41,15 @@ const HomeSpeedDial: React.FC = ({ onOpen }) => { tooltipTitle={i18n.t('abortAllButton')} onClick={abort} /> + } + tooltipTitle={i18n.t('templatesEditor')} + onClick={onEditorOpen} + /> } - tooltipTitle={`New download`} - onClick={onOpen} + tooltipTitle={i18n.t('newDownload')} + onClick={onDownloadOpen} /> ) diff --git a/frontend/src/components/SocketSubscriber.tsx b/frontend/src/components/SocketSubscriber.tsx index d2bde78..488a4bf 100644 --- a/frontend/src/components/SocketSubscriber.tsx +++ b/frontend/src/components/SocketSubscriber.tsx @@ -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 { } const SocketSubscriber: React.FC = ({ 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 = ({ children }) => { } ) - useSubscription(timer(0, 1000), () => client.running()) + useEffect(() => { + if (connected) { + timer(0, 1000).subscribe(() => client.running()) + } + }, [connected]) return ( <>{children} diff --git a/frontend/src/components/TemplatesEditor.tsx b/frontend/src/components/TemplatesEditor.tsx new file mode 100644 index 0000000..cf2c5b5 --- /dev/null +++ b/frontend/src/components/TemplatesEditor.tsx @@ -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, +) { + return +}) + +interface Props extends React.HTMLAttributes { + open: boolean + onClose: () => void +} + +const TemplatesEditor: React.FC = ({ open, onClose }) => { + const [templateName, setTemplateName] = useState('') + const [templateContent, setTemplateContent] = useState('') + + const serverAddr = useRecoilValue(serverURL) + const [isPending, startTransition] = useTransition() + + const [templates, setTemplates] = useState([]) + + const { i18n } = useI18n() + const { pushMessage } = useToast() + + useEffect(() => { + if (open) { + getTemplates() + } + }, [open]) + + const getTemplates = async () => { + const task = ffetch(`${serverAddr}/api/v1/template/all`) + const either = await task() + + pipe( + either, + matchW( + (l) => pushMessage(l), + (r) => setTemplates(r) + ) + ) + } + + const addTemplate = async () => { + const task = ffetch(`${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(`${serverAddr}/api/v1/template/${id}`, { + method: 'DELETE', + }) + + const either = await task() + + pipe( + either, + matchW( + (l) => pushMessage(l, 'warning'), + () => { + pushMessage('Deleted template') + getTemplates() + } + ) + ) + } + + return ( + + theme.zIndex.drawer + 1 }} + open={isPending} + /> + + + + + + + {i18n.t('templatesEditor')} + + + + theme.palette.background.default, + minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)` + }}> + + + + + + setTemplateName(e.currentTarget.value)} + value={templateName} + /> + + + setTemplateContent(e.currentTarget.value)} + value={templateContent} + InputProps={{ + endAdornment: + }} + /> + + + {templates.map(template => ( + + + + + + { + startTransition(() => { deleteTemplate(template.id) }) + }}> + + + }} + /> + + + ))} + + + + + + ) +} + +export default TemplatesEditor \ No newline at end of file diff --git a/frontend/src/lib/rpcClient.ts b/frontend/src/lib/rpcClient.ts index 9ad0c4c..ee70edc 100644 --- a/frontend/src/lib/rpcClient.ts +++ b/frontend/src/lib/rpcClient.ts @@ -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(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, }] }) } diff --git a/frontend/src/providers/ToasterProvider.tsx b/frontend/src/providers/ToasterProvider.tsx index 599dc9e..09191d2 100644 --- a/frontend/src/providers/ToasterProvider.tsx +++ b/frontend/src/providers/ToasterProvider.tsx @@ -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) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8b7cbc2..c0bb39a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -83,3 +83,8 @@ export type DeleteRequest = Pick export type PlayRequest = Pick +export type CustomTemplate = { + id: string + name: string + content: string +} \ No newline at end of file diff --git a/go.mod b/go.mod index 8ba4a6a..c864645 100644 --- a/go.mod +++ b/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 +) diff --git a/go.sum b/go.sum index 1e145c4..10ab0ae 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index 7627764..72b9a15 100644 --- a/main.go +++ b/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) } diff --git a/server/dbutils/migrate.go b/server/dbutils/migrate.go new file mode 100644 index 0000000..5ec1de3 --- /dev/null +++ b/server/dbutils/migrate.go @@ -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 +} diff --git a/server/internal/common.go b/server/internal/common.go index 0d7ec0d..31e99a4 100644 --- a/server/internal/common.go +++ b/server/internal/common.go @@ -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"` +} diff --git a/server/rest/container.go b/server/rest/container.go index 7955c67..0966d13 100644 --- a/server/rest/container.go +++ b/server/rest/container.go @@ -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()) } } diff --git a/server/rest/handlers.go b/server/rest/handlers.go index 1869541..b275c43 100644 --- a/server/rest/handlers.go +++ b/server/rest/handlers.go @@ -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) + } + } +} diff --git a/server/rest/provider.go b/server/rest/provider.go index 37556aa..b6da94e 100644 --- a/server/rest/provider.go +++ b/server/rest/provider.go @@ -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 diff --git a/server/rest/service.go b/server/rest/service.go index 954b9a1..487eec6 100644 --- a/server/rest/service.go +++ b/server/rest/service.go @@ -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 +} diff --git a/server/server.go b/server/server.go index 16e8f29..61a7bd6 100644 --- a/server/server.go +++ b/server/server.go @@ -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),