Compare commits

..

16 Commits

Author SHA1 Message Date
19f9b10844 templates editor #62 2023-10-30 11:48:10 +01:00
cd5c210eac code refactoring 2023-10-29 15:07:42 +01:00
252d2f2845 fixed potential race condition 2023-10-28 12:10:50 +02:00
c320058af3 bug fix 2023-10-28 10:38:26 +02:00
062e07aa98 code refactoring, backend code optimization 2023-10-28 10:32:28 +02:00
2564751405 small fixes 2023-10-26 17:53:39 +02:00
ce838b8fb0 Dockerfile refactor 2023-10-25 14:08:00 +02:00
1aadce8dd7 Dockerfile refactor 2023-10-25 14:04:02 +02:00
d5695077c4 Dockerfile refactor 2023-10-25 13:57:55 +02:00
2c238957d0 Dockerfile refactor 2023-10-25 13:55:39 +02:00
d47b8496a7 added example nginx and caddy configs 2023-10-25 13:46:13 +02:00
2f8b4cd1d6 config package code refactor 2023-10-24 16:18:07 +02:00
b512e996ad config package refactor 2023-10-24 16:07:38 +02:00
0c7415df28 code refactoring 2023-10-24 15:29:20 +02:00
38d8bb8e40 code refactoring 2023-10-24 14:45:55 +02:00
Marco
ba23485b33 97 custom arguments broken (#99)
* golang debug

* handle template in playlist download

* code refactoring, dropped goccy go json
2023-10-22 15:54:08 +02:00
48 changed files with 1082 additions and 374 deletions

View File

@@ -15,3 +15,4 @@ yt-dlp-webui
session.dat session.dat
config.yml config.yml
cookies.txt cookies.txt
examples/

4
.vscode/launch.json vendored
View File

@@ -5,11 +5,11 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Launch file", "name": "go",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "debug", "mode": "debug",
"program": "${file}" "program": "main.go"
}, },
{ {
"type": "chrome", "type": "chrome",

View File

@@ -1,11 +1,12 @@
FROM golang:1.20-alpine AS build FROM golang:alpine AS build
RUN apk update && \ RUN apk update && \
apk add nodejs npm go apk add nodejs npm
COPY . /usr/src/yt-dlp-webui COPY . /usr/src/yt-dlp-webui
WORKDIR /usr/src/yt-dlp-webui/frontend WORKDIR /usr/src/yt-dlp-webui/frontend
RUN npm install RUN npm install
RUN npm run build RUN npm run build
@@ -26,4 +27,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret ENV JWT_SECRET=secret
EXPOSE 3033 EXPOSE 3033
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml" ] ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ]

View File

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

5
examples/Caddyfile Normal file
View File

@@ -0,0 +1,5 @@
resource.yourdomain.tld {
handle_path /yt-dlp-webui/* {
reverse_proxy 127.0.0.1:3033
}
}

37
examples/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
server {
listen 80;
server_name localhost;
location ~/yt-dlp/(.*)$ {
proxy_pass http://127.0.0.1:3033/$1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 20000m;
proxy_connect_timeout 3000;
proxy_send_timeout 3000;
proxy_read_timeout 3000;
send_timeout 3000;
}
}
}

View File

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

View File

@@ -1,9 +1,52 @@
import { atom } from 'recoil' import { atom, selector } from 'recoil'
import { CustomTemplate } from '../types'
import { ffetch } from '../lib/httpClient'
import { serverURL } from './settings'
import { pipe } from 'fp-ts/lib/function'
import { getOrElse } from 'fp-ts/lib/Either'
export const downloadTemplateState = atom({ export const cookiesTemplateState = atom({
key: 'downloadTemplateState', key: 'cookiesTemplateState',
default: localStorage.getItem('lastDownloadTemplate') ?? '', default: localStorage.getItem('cookiesTemplate') ?? '',
effects: [ effects: [
({ onSet }) => onSet(e => localStorage.setItem('lastDownloadTemplate', e)) ({ onSet }) => onSet(e => localStorage.setItem('cookiesTemplate', e))
] ]
}) })
export const customArgsState = atom({
key: 'customArgsState',
default: localStorage.getItem('customArgs') ?? '',
effects: [
({ onSet }) => onSet(e => localStorage.setItem('customArgs', e))
]
})
export const filenameTemplateState = atom({
key: 'filenameTemplateState',
default: localStorage.getItem('lastFilenameTemplate') ?? '',
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
})

View File

@@ -1,7 +1,22 @@
import { atom } from 'recoil' import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/lib/function'
import { atom, selector } from 'recoil'
import { RPCResult } from '../types' import { RPCResult } from '../types'
export const activeDownloadsState = atom<RPCResult[] | undefined>({ export const downloadsState = atom<O.Option<RPCResult[]>>({
key: 'activeDownloadsState', key: 'downloadsState',
default: undefined default: O.none
})
export const loadingDownloadsState = selector<boolean>({
key: 'loadingDownloadsState',
get: ({ get }) => O.isNone(get(downloadsState))
})
export const activeDownloadsState = selector<RPCResult[]>({
key: 'activeDownloadsState',
get: ({ get }) => pipe(
get(downloadsState),
O.getOrElse(() => new Array<RPCResult>())
)
}) })

View File

@@ -153,7 +153,7 @@ export const serverAddressAndPortState = selector({
export const serverURL = selector({ export const serverURL = selector({
key: 'serverURL', key: 'serverURL',
get: ({ get }) => get: ({ get }) =>
`${window.location.protocol}//${get(serverAddressState)}:${get(serverPortState)}` `${window.location.protocol}//${get(serverAddressAndPortState)}`
}) })
export const rpcWebSocketEndpoint = selector({ export const rpcWebSocketEndpoint = selector({

View File

@@ -1,7 +1,7 @@
import { AlertColor } from '@mui/material' import { AlertColor } from '@mui/material'
import { atom } from 'recoil' import { atom } from 'recoil'
type Toast = { export type Toast = {
open: boolean, open: boolean,
message: string message: string
autoClose: boolean autoClose: boolean

View File

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

View File

@@ -1,7 +1,9 @@
import { FileUpload } from '@mui/icons-material' import { FileUpload } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import { import {
Autocomplete,
Backdrop, Backdrop,
Box,
Button, Button,
Checkbox, Checkbox,
Container, Container,
@@ -10,10 +12,7 @@ import {
Grid, Grid,
IconButton, IconButton,
InputAdornment, InputAdornment,
InputLabel,
MenuItem,
Paper, Paper,
Select,
TextField TextField
} from '@mui/material' } from '@mui/material'
import AppBar from '@mui/material/AppBar' import AppBar from '@mui/material/AppBar'
@@ -30,7 +29,7 @@ import {
useTransition useTransition
} from 'react' } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { downloadTemplateState } from '../atoms/downloadTemplate' import { customArgsState, downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate'
import { settingsState } from '../atoms/settings' import { settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status' import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
@@ -39,6 +38,7 @@ import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser' import { CliArguments } from '../lib/argsParser'
import type { DLMetadata } from '../types' import type { DLMetadata } from '../types'
import { isValidURL, toFormatArgs } from '../utils' import { isValidURL, toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions'
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
@@ -60,41 +60,38 @@ export default function DownloadDialog({
onClose, onClose,
onDownloadStart onDownloadStart
}: Props) { }: Props) {
// recoil state
const settings = useRecoilValue(settingsState) const settings = useRecoilValue(settingsState)
const isConnected = useRecoilValue(connectedState) const isConnected = useRecoilValue(connectedState)
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState) const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
const downloadTemplate = useRecoilValue(downloadTemplateState)
// ephemeral state
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>() const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState('') const [pickedVideoFormat, setPickedVideoFormat] = useState('')
const [pickedAudioFormat, setPickedAudioFormat] = useState('') const [pickedAudioFormat, setPickedAudioFormat] = useState('')
const [pickedBestFormat, setPickedBestFormat] = useState('') const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState) const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [downloadPath, setDownloadPath] = useState(0) const [downloadPath, setDownloadPath] = useState('')
const [fileNameOverride, setFilenameOverride] = useState('') const [filenameTemplate, setFilenameTemplate] = useRecoilState(
filenameTemplateState
)
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [workingUrl, setWorkingUrl] = useState('') const [workingUrl, setWorkingUrl] = useState('')
const [isPlaylist, setIsPlaylist] = useState(false) const [isPlaylist, setIsPlaylist] = useState(false)
// memos
const cliArgs = useMemo(() => const cliArgs = useMemo(() =>
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs] new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
) )
// context
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
// refs
const urlInputRef = useRef<HTMLInputElement>(null) const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null) const customFilenameInputRef = useRef<HTMLInputElement>(null)
// transitions
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
/** /**
@@ -106,13 +103,13 @@ export default function DownloadDialog({
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat) if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat) if (pickedBestFormat !== '') codes.push(pickedBestFormat)
client.download( client.download({
immediate || url || workingUrl, url: immediate || url || workingUrl,
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`, args: `${cliArgs.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
availableDownloadPaths[downloadPath] ?? '', pathOverride: downloadPath ?? '',
fileNameOverride, renameTo: settings.fileRenaming ? filenameTemplate : '',
isPlaylist, playlist: isPlaylist,
) })
setUrl('') setUrl('')
setWorkingUrl('') setWorkingUrl('')
@@ -141,30 +138,16 @@ export default function DownloadDialog({
}) })
} }
/**
* Update the url state whenever the input value changes
* @param e Input change event
*/
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value) setUrl(e.target.value)
} }
/** const handleFilenameTemplateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
* Update the filename override state whenever the input value changes setFilenameTemplate(e.target.value)
* @param e Input change event
*/
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilenameOverride(e.target.value)
localStorage.setItem('last-filename-override', e.target.value)
} }
/**
* Update the custom args state whenever the input value changes
* @param e Input change event
*/
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomArgs(e.target.value) setCustomArgs(e.target.value)
localStorage.setItem("last-input-args", e.target.value)
} }
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => { const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -189,7 +172,6 @@ export default function DownloadDialog({
} }
return ( return (
<div>
<Dialog <Dialog
fullScreen fullScreen
open={open} open={open}
@@ -215,10 +197,15 @@ export default function DownloadDialog({
</Typography> </Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Container sx={{ my: 4 }}> <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 container spacing={2}>
<Grid item xs={12}> <Grid item xs={12}>
<Paper <Paper
elevation={4}
sx={{ sx={{
p: 2, p: 2,
display: 'flex', display: 'flex',
@@ -279,14 +266,15 @@ export default function DownloadDialog({
} }
{ {
settings.fileRenaming && settings.fileRenaming &&
<Grid item xs={8}> <Grid item xs={settings.pathOverriding ? 8 : 12}>
<TextField <TextField
sx={{ mt: 1 }}
ref={customFilenameInputRef} ref={customFilenameInputRef}
fullWidth fullWidth
label={i18n.t('customFilename')} label={i18n.t('customFilename')}
variant="outlined" variant="outlined"
value={fileNameOverride} value={filenameTemplate}
onChange={handleFilenameOverrideChange} onChange={handleFilenameTemplateChange}
disabled={ disabled={
!isConnected || !isConnected ||
(settings.formatSelection && downloadFormats != null) (settings.formatSelection && downloadFormats != null)
@@ -298,22 +286,30 @@ export default function DownloadDialog({
settings.pathOverriding && settings.pathOverriding &&
<Grid item xs={4}> <Grid item xs={4}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('customPath')}</InputLabel> <Autocomplete
<Select disablePortal
label={i18n.t('customPath')} options={availableDownloadPaths.map((dir) => ({ label: dir, dir }))}
defaultValue={0} autoHighlight
variant={'outlined'} getOptionLabel={(option) => option.label}
value={downloadPath} onChange={(_, value) => {
onChange={(e) => setDownloadPath(Number(e.target.value))} setDownloadPath(value?.dir!)
> }}
{availableDownloadPaths.map((val: string, idx: number) => ( renderOption={(props, option) => (
<MenuItem key={idx} value={idx}>{val}</MenuItem> <Box
))} component="li"
</Select> sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...props}>
{option.label}
</Box>
)}
sx={{ width: '100%', mt: 1 }}
renderInput={(params) => <TextField {...params} label={i18n.t('customPath')} />}
/>
</FormControl> </FormControl>
</Grid> </Grid>
} }
</Grid> </Grid>
<ExtraDownloadOptions />
<Grid container spacing={1} pt={2} justifyContent="space-between"> <Grid container spacing={1} pt={2} justifyContent="space-between">
<Grid item> <Grid item>
<Button <Button
@@ -369,7 +365,7 @@ export default function DownloadDialog({
pickedAudioFormat={pickedAudioFormat} pickedAudioFormat={pickedAudioFormat}
/>} />}
</Container> </Container>
</Box>
</Dialog> </Dialog>
</div>
) )
} }

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads' import { loadingDownloadsState } from '../atoms/downloads'
import { listViewState } from '../atoms/settings' import { listViewState } from '../atoms/settings'
import { loadingAtom } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
import DownloadsCardView from './DownloadsCardView' import DownloadsCardView from './DownloadsCardView'
@@ -8,15 +8,17 @@ import DownloadsListView from './DownloadsListView'
const Downloads: React.FC = () => { const Downloads: React.FC = () => {
const listView = useRecoilValue(listViewState) const listView = useRecoilValue(listViewState)
const active = useRecoilValue(activeDownloadsState) const loadingDownloads = useRecoilValue(loadingDownloadsState)
const [isLoading, setIsLoading] = useRecoilState(loadingAtom) const [isLoading, setIsLoading] = useRecoilState(loadingAtom)
useEffect(() => { useEffect(() => {
if (active) { if (loadingDownloads) {
setIsLoading(false) setIsLoading(true)
return
} }
}, [active?.length, isLoading]) setIsLoading(false)
}, [loadingDownloads, isLoading])
if (listView) { if (listView) {
return ( return (

View File

@@ -7,7 +7,7 @@ import { useRPC } from '../hooks/useRPC'
import DownloadCard from './DownloadCard' import DownloadCard from './DownloadCard'
const DownloadsCardView: React.FC = () => { const DownloadsCardView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? [] const downloads = useRecoilValue(activeDownloadsState)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()

View File

@@ -18,7 +18,7 @@ import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
const DownloadsListView: React.FC = () => { const DownloadsListView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? [] const downloads = useRecoilValue(activeDownloadsState)
const { client } = useRPC() const { client } = useRPC()

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react' import * as O from 'fp-ts/Option'
import { useEffect, useMemo } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { interval, share, take } from 'rxjs' import { share, take, timer } from 'rxjs'
import { activeDownloadsState } from '../atoms/downloads' import { downloadsState } from '../atoms/downloads'
import { serverAddressAndPortState } from '../atoms/settings' import { serverAddressAndPortState } from '../atoms/settings'
import { connectedState } from '../atoms/status' import { connectedState } from '../atoms/status'
import { useSubscription } from '../hooks/observable' import { useSubscription } from '../hooks/observable'
@@ -13,8 +14,8 @@ import { datetimeCompareFunc, isRPCResponse } from '../utils'
interface Props extends React.HTMLAttributes<HTMLBaseElement> { } interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
const SocketSubscriber: React.FC<Props> = ({ children }) => { const SocketSubscriber: React.FC<Props> = ({ children }) => {
const [, setIsConnected] = useRecoilState(connectedState) const [connected, setIsConnected] = useRecoilState(connectedState)
const [, setActive] = useRecoilState(activeDownloadsState) const [, setDownloads] = useRecoilState(downloadsState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState) const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
@@ -33,21 +34,26 @@ const SocketSubscriber: React.FC<Props> = ({ children }) => {
) )
}) })
useSubscription(sharedSocket$, useSubscription(
(event) => { sharedSocket$,
event => {
if (!isRPCResponse(event)) { return } if (!isRPCResponse(event)) { return }
if (!Array.isArray(event.result)) { return } if (!Array.isArray(event.result)) { return }
setActive( if (event.result) {
(event.result || []) return setDownloads(
.filter(f => !!f.info.url) O.of(event.result
.sort((a, b) => datetimeCompareFunc( .filter(f => !!f.info.url).sort((a, b) => datetimeCompareFunc(
b.info.created_at, b.info.created_at,
a.info.created_at, a.info.created_at,
)) ))
) )
)
}
setDownloads(O.none)
}, },
(err) => { err => {
console.error(err) console.error(err)
pushMessage( pushMessage(
`${i18n.t('rpcConnErr')} (${serverAddressAndPort})`, `${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
@@ -56,7 +62,11 @@ const SocketSubscriber: React.FC<Props> = ({ children }) => {
} }
) )
useSubscription(interval(1000), () => client.running()) useEffect(() => {
if (connected) {
timer(0, 1000).subscribe(() => client.running())
}
}, [connected])
return ( return (
<>{children}</> <>{children}</>

View File

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

View File

@@ -1,13 +1,11 @@
import { tryCatch } from 'fp-ts/TaskEither' import { tryCatch } from 'fp-ts/TaskEither'
import { flow } from 'fp-ts/lib/function'
export const ffetch = <T>(url: string, opt?: RequestInit) => flow( export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
tryCatch(
() => fetcher<T>(url, opt), () => fetcher<T>(url, opt),
(e) => `error while fetching: ${e}` (e) => `error while fetching: ${e}`
)
) )
const fetcher = async <T>(url: string, opt?: RequestInit) => { const fetcher = async <T>(url: string, opt?: RequestInit) => {
const res = await fetch(url, opt) const res = await fetch(url, opt)

View File

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

View File

@@ -1,20 +1,22 @@
import { Alert, Snackbar } from "@mui/material" import { Alert, Snackbar } from "@mui/material"
import { useRecoilState } from 'recoil' import { useRecoilState } from 'recoil'
import { toastListState } from '../atoms/toast' import { Toast, toastListState } from '../atoms/toast'
import { useEffect } from 'react' import { useEffect } from 'react'
const Toaster: React.FC = () => { const Toaster: React.FC = () => {
const [toasts, setToasts] = useRecoilState(toastListState) const [toasts, setToasts] = useRecoilState(toastListState)
const deletePredicate = (t: Toast) => (Date.now() - t.createdAt) < 2000
useEffect(() => { useEffect(() => {
if (toasts.length > 0) { if (toasts.length > 0) {
const closer = setInterval(() => { const closer = setInterval(() => {
setToasts(t => t.map(t => ({ ...t, open: false }))) setToasts(t => t.map(t => ({ ...t, open: deletePredicate(t) })))
}, 2000) }, 900)
const cleaner = setInterval(() => { const cleaner = setInterval(() => {
setToasts(t => t.filter((x) => (Date.now() - x.createdAt) < 2000)) setToasts(t => t.filter(deletePredicate))
}, 2250) }, 2005)
return () => { return () => {
clearInterval(closer) clearInterval(closer)
@@ -29,7 +31,7 @@ const Toaster: React.FC = () => {
<Snackbar <Snackbar
key={index} key={index}
open={toast.open} open={toast.open}
sx={index > 0 ? { marginBottom: index * 6.5 } : {}} sx={index > 0 ? { marginBottom: index * 6.5 } : null}
> >
<Alert variant="filled" severity={toast.severity}> <Alert variant="filled" severity={toast.severity}>
{toast.message} {toast.message}

View File

@@ -16,11 +16,11 @@ export type RPCRequest = {
id?: string id?: string
} }
export type RPCResponse<T> = { export type RPCResponse<T> = Readonly<{
result: T result: T
error: number | null error: number | null
id?: string id?: string
} }>
type DownloadInfo = { type DownloadInfo = {
url: string url: string
@@ -41,18 +41,18 @@ type DownloadProgress = {
process_status: number process_status: number
} }
export type RPCResult = { export type RPCResult = Readonly<{
id: string id: string
progress: DownloadProgress progress: DownloadProgress
info: DownloadInfo info: DownloadInfo
} }>
export type RPCParams = { export type RPCParams = {
URL: string URL: string
Params?: string Params?: string
} }
export interface DLMetadata { export type DLMetadata = {
formats: Array<DLFormat> formats: Array<DLFormat>
best: DLFormat best: DLFormat
thumbnail: string thumbnail: string
@@ -83,3 +83,8 @@ export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
export type PlayRequest = Pick<DirectoryEntry, 'path'> export type PlayRequest = Pick<DirectoryEntry, 'path'>
export type CustomTemplate = {
id: string
name: string
content: string
}

View File

@@ -66,7 +66,7 @@ export default function Downloaded() {
), ),
matchW( matchW(
(e) => { (e) => {
pushMessage(e) pushMessage(e, 'error')
navigate('/login') navigate('/login')
}, },
(d) => files$.next(d), (d) => files$.next(d),
@@ -87,24 +87,32 @@ export default function Downloaded() {
? ['.', ..._upperLevel].join('/') ? ['.', ..._upperLevel].join('/')
: _upperLevel.join('/') : _upperLevel.join('/')
fetch(`${serverAddr}/archive/downloaded`, { const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ subdir: relpath }) body: JSON.stringify({ subdir: relpath })
}) })
.then(res => res.json())
.then(data => { pipe(
files$.next(sub task,
matchW(
(l) => pushMessage(l, 'error'),
(r) => files$.next(sub
? [{ ? [{
name: '..',
isDirectory: true, isDirectory: true,
isVideo: false,
modTime: '',
name: '..',
path: upperLevel, path: upperLevel,
}, ...data] shaSum: '',
: data size: 0,
}, ...r]
: r
) )
}) )
)()
} }
const selectable$ = useMemo(() => files$.pipe( const selectable$ = useMemo(() => files$.pipe(

View File

@@ -15,6 +15,10 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
const LoginContainer = styled(Container)({ const LoginContainer = styled(Container)({
display: 'flex', display: 'flex',
@@ -42,13 +46,15 @@ export default function Login() {
const navigate = useNavigate() const navigate = useNavigate()
const { pushMessage } = useToast()
const navigateAndReload = () => { const navigateAndReload = () => {
navigate('/') navigate('/')
window.location.reload() window.location.reload()
} }
const login = async () => { const login = async () => {
const res = await fetch(`${url}/auth/login`, { const task = ffetch(`${url}/auth/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -56,9 +62,20 @@ export default function Login() {
body: JSON.stringify({ body: JSON.stringify({
username, username,
password, password,
}),
redirect: 'follow'
}) })
})
res.ok ? navigateAndReload() : setFormHasError(true) pipe(
task,
matchW(
(l) => {
setFormHasError(true)
pushMessage(l, 'error')
},
() => navigateAndReload()
)
)()
} }
return ( return (

21
go.mod
View File

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

58
go.sum
View File

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

22
main.go
View File

@@ -20,6 +20,7 @@ var (
downloadPath string downloadPath string
downloaderPath string downloaderPath string
sessionFilePath string sessionFilePath string
localDatabasePath string
requireAuth bool requireAuth bool
username string username string
@@ -42,6 +43,7 @@ func init() {
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path") flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.StringVar(&sessionFilePath, "session", ".", "session file path") flag.StringVar(&sessionFilePath, "session", ".", "session file path")
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication") flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
flag.StringVar(&username, "user", userFromEnv, "Username required for auth") flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
@@ -59,20 +61,20 @@ func main() {
c := config.Instance() c := config.Instance()
c.SetPort(port) c.Port = port
c.QueueSize(queueSize) c.QueueSize = queueSize
c.DownloadPath(downloadPath) c.DownloadPath = downloadPath
c.DownloaderPath(downloaderPath) c.DownloaderPath = downloaderPath
c.SessionFilePath(sessionFilePath) c.SessionFilePath = sessionFilePath
c.RequireAuth(requireAuth) c.RequireAuth = requireAuth
c.Username(username) c.Username = username
c.Password(password) c.Password = password
// if config file is found it will be merged with the current config struct // if config file is found it will be merged with the current config struct
if _, err := c.LoadFromFile(configFile); err != nil { if err := c.LoadFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, "no config file found") log.Println(cli.BgRed, "config", cli.Reset, "no config file found")
} }
server.RunBlocking(port, frontend) server.RunBlocking(port, frontend, localDatabasePath)
} }

View File

@@ -7,9 +7,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var lock sync.Mutex type Config struct {
type serverConfig struct {
Port int `yaml:"port"` Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"` DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"` DownloaderPath string `yaml:"downloaderPath"`
@@ -20,68 +18,29 @@ type serverConfig struct {
SessionFilePath string `yaml:"session_file_path"` SessionFilePath string `yaml:"session_file_path"`
} }
type config struct { var (
cfg serverConfig instance *Config
} instanceOnce sync.Once
)
func (c *config) LoadFromFile(filename string) (serverConfig, error) { func Instance() *Config {
fd, err := os.Open(filename)
if err != nil {
return serverConfig{}, err
}
if err := yaml.NewDecoder(fd).Decode(&c.cfg); err != nil {
return serverConfig{}, err
}
return c.cfg, nil
}
func (c *config) GetConfig() serverConfig {
return c.cfg
}
func (c *config) SetPort(port int) {
c.cfg.Port = port
}
func (c *config) DownloadPath(path string) {
c.cfg.DownloadPath = path
}
func (c *config) DownloaderPath(path string) {
c.cfg.DownloaderPath = path
}
func (c *config) RequireAuth(value bool) {
c.cfg.RequireAuth = value
}
func (c *config) Username(username string) {
c.cfg.Username = username
}
func (c *config) Password(password string) {
c.cfg.Password = password
}
func (c *config) QueueSize(size int) {
c.cfg.QueueSize = size
}
func (c *config) SessionFilePath(path string) {
c.cfg.SessionFilePath = path
}
var instance *config
func Instance() *config {
if instance == nil { if instance == nil {
lock.Lock() instanceOnce.Do(func() {
defer lock.Unlock() instance = &Config{}
if instance == nil { })
instance = &config{serverConfig{}}
}
} }
return instance return instance
} }
func (c *Config) LoadFile(filename string) error {
fd, err := os.Open(filename)
if err != nil {
return err
}
if err := yaml.NewDecoder(fd).Decode(c); err != nil {
return err
}
return nil
}

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

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

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -11,7 +12,6 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/goccy/go-json"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils" "github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
) )
@@ -66,7 +66,7 @@ type ListRequest struct {
} }
func ListDownloaded(w http.ResponseWriter, r *http.Request) { func ListDownloaded(w http.ResponseWriter, r *http.Request) {
root := config.Instance().GetConfig().DownloadPath root := config.Instance().DownloadPath
req := new(ListRequest) req := new(ListRequest)
err := json.NewDecoder(r.Body).Decode(&req) err := json.NewDecoder(r.Body).Decode(&req)
@@ -144,7 +144,7 @@ func SendFile(w http.ResponseWriter, r *http.Request) {
decodedStr := string(decoded) decodedStr := string(decoded)
root := config.Instance().GetConfig().DownloadPath root := config.Instance().DownloadPath
// TODO: further path / file validations // TODO: further path / file validations
if strings.Contains(filepath.Dir(decodedStr), root) { if strings.Contains(filepath.Dir(decodedStr), root) {

View File

@@ -1,11 +1,11 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"os" "os"
"time" "time"
"github.com/goccy/go-json"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils" "github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
@@ -25,8 +25,8 @@ func Login(w http.ResponseWriter, r *http.Request) {
} }
var ( var (
username = config.Instance().GetConfig().Username username = config.Instance().Username
password = config.Instance().GetConfig().Password password = config.Instance().Password
) )
if username != req.Username || password != req.Password { if username != req.Username || password != req.Password {

View File

@@ -7,7 +7,7 @@ type DownloadProgress struct {
Status int `json:"process_status"` Status int `json:"process_status"`
Percentage string `json:"percentage"` Percentage string `json:"percentage"`
Speed float32 `json:"speed"` Speed float32 `json:"speed"`
ETA int `json:"eta"` ETA float32 `json:"eta"`
} }
// Used to deser the yt-dlp -J output // Used to deser the yt-dlp -J output
@@ -50,6 +50,8 @@ type ProcessResponse struct {
Id string `json:"id"` Id string `json:"id"`
Progress DownloadProgress `json:"progress"` Progress DownloadProgress `json:"progress"`
Info DownloadInfo `json:"info"` Info DownloadInfo `json:"info"`
Output DownloadOutput `json:"output"`
Params []string `json:"params"`
} }
// struct representing the current status of the memoryDB // struct representing the current status of the memoryDB
@@ -76,3 +78,9 @@ type DownloadRequest struct {
type SetCookiesRequest struct { type SetCookiesRequest struct {
Cookies string `json:"cookies"` Cookies string `json:"cookies"`
} }
type CustomTemplate struct {
Id string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
}

View File

@@ -30,7 +30,7 @@ func (m *MemoryDB) Get(id string) (*Process, error) {
// Store a pointer of a process and return its id // Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string { func (m *MemoryDB) Set(process *Process) string {
id := uuid.Must(uuid.NewRandom()).String() id := uuid.NewString()
m.table.Store(id, process) m.table.Store(id, process)
process.Id = id process.Id = id
return id return id
@@ -85,6 +85,8 @@ func (m *MemoryDB) All() *[]ProcessResponse {
Id: key.(string), Id: key.(string),
Info: value.(*Process).Info, Info: value.(*Process).Info,
Progress: value.(*Process).Progress, Progress: value.(*Process).Progress,
Output: value.(*Process).Output,
Params: value.(*Process).Params,
}) })
return true return true
}) })
@@ -95,12 +97,9 @@ func (m *MemoryDB) All() *[]ProcessResponse {
func (m *MemoryDB) Persist() { func (m *MemoryDB) Persist() {
running := m.All() running := m.All()
sessionFile := filepath.Join( sf := filepath.Join(config.Instance().SessionFilePath, "session.dat")
config.Instance().GetConfig().SessionFilePath,
"session.dat",
)
fd, err := os.Create(sessionFile) fd, err := os.Create(sf)
if err != nil { if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset) log.Println(cli.Red, "Failed to persist session", cli.Reset)
} }
@@ -137,6 +136,8 @@ func (m *MemoryDB) Restore() {
Url: proc.Info.URL, Url: proc.Info.URL,
Info: proc.Info, Info: proc.Info,
Progress: proc.Progress, Progress: proc.Progress,
Output: proc.Output,
Params: proc.Params,
} }
m.table.Store(proc.Id, restored) m.table.Store(proc.Id, restored)

View File

@@ -16,7 +16,7 @@ type MessageQueue struct {
// CPU cores. // CPU cores.
// The queue size can be set via the qs flag. // The queue size can be set via the qs flag.
func NewMessageQueue() *MessageQueue { func NewMessageQueue() *MessageQueue {
size := config.Instance().GetConfig().QueueSize size := config.Instance().QueueSize
if size <= 0 { if size <= 0 {
log.Fatalln("invalid queue size") log.Fatalln("invalid queue size")

View File

@@ -1,12 +1,12 @@
package internal package internal
import ( import (
"encoding/json"
"errors" "errors"
"log" "log"
"os/exec" "os/exec"
"time" "time"
"github.com/goccy/go-json"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
@@ -19,7 +19,7 @@ type metadata struct {
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error { func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
var ( var (
downloader = config.Instance().GetConfig().DownloaderPath downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "-J") cmd = exec.Command(downloader, req.URL, "-J")
) )
@@ -60,7 +60,9 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
proc := &Process{ proc := &Process{
Url: meta.OriginalURL, Url: meta.OriginalURL,
Progress: DownloadProgress{}, Progress: DownloadProgress{},
Output: DownloadOutput{}, Output: DownloadOutput{
Filename: req.Rename,
},
Info: meta, Info: meta,
Params: req.Params, Params: req.Params,
} }

View File

@@ -2,13 +2,12 @@ package internal
import ( import (
"bufio" "bufio"
"encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"sync" "sync"
"syscall" "syscall"
"github.com/goccy/go-json"
"log" "log"
"os" "os"
"os/exec" "os/exec"
@@ -18,6 +17,7 @@ import (
"github.com/marcopeocchi/fazzoletti/slices" "github.com/marcopeocchi/fazzoletti/slices"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
) )
const template = `download: const template = `download:
@@ -38,7 +38,7 @@ type ProgressTemplate struct {
Percentage string `json:"percentage"` Percentage string `json:"percentage"`
Speed float32 `json:"speed"` Speed float32 `json:"speed"`
Size string `json:"size"` Size string `json:"size"`
Eta int `json:"eta"` Eta float32 `json:"eta"`
} }
// Process descriptor // Process descriptor
@@ -70,14 +70,19 @@ func (p *Process) Start() {
return !match return !match
}) })
p.Params = slices.Filter(p.Params, func(e string) bool {
return e != ""
})
out := DownloadOutput{ out := DownloadOutput{
Path: config.Instance().GetConfig().DownloadPath, Path: config.Instance().DownloadPath,
Filename: "%(title)s.%(ext)s", Filename: "%(title)s.%(ext)s",
} }
if p.Output.Path != "" { if p.Output.Path != "" {
out.Path = p.Output.Path out.Path = p.Output.Path
} }
if p.Output.Filename != "" { if p.Output.Filename != "" {
out.Filename = p.Output.Filename + ".%(ext)s" out.Filename = p.Output.Filename + ".%(ext)s"
} }
@@ -87,13 +92,14 @@ func (p *Process) Start() {
"--newline", "--newline",
"--no-colors", "--no-colors",
"--no-playlist", "--no-playlist",
"--progress-template", strings.ReplaceAll(template, "\n", ""), "--progress-template",
strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(template),
"-o", "-o",
fmt.Sprintf("%s/%s", out.Path, out.Filename), fmt.Sprintf("%s/%s", out.Path, out.Filename),
}, p.Params...) }, p.Params...)
// ----------------- main block ----------------- // // ----------------- main block ----------------- //
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, params...) cmd := exec.Command(config.Instance().DownloaderPath, params...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
r, err := cmd.StdoutPipe() r, err := cmd.StdoutPipe()
@@ -109,11 +115,11 @@ func (p *Process) Start() {
p.proc = cmd.Process p.proc = cmd.Process
// ----------------- info block ----------------- //
// spawn a goroutine that retrieves the info for the download
// --------------- progress block --------------- // // --------------- progress block --------------- //
// unbuffered channel connected to stdout var (
sourceChan = make(chan []byte)
doneChan = make(chan struct{})
)
// spawn a goroutine that does the dirty job of parsing the stdout // spawn a goroutine that does the dirty job of parsing the stdout
// filling the channel with as many stdout line as yt-dlp produces (producer) // filling the channel with as many stdout line as yt-dlp produces (producer)
@@ -121,11 +127,21 @@ func (p *Process) Start() {
defer func() { defer func() {
r.Close() r.Close()
p.Complete() p.Complete()
doneChan <- struct{}{}
close(sourceChan)
close(doneChan)
}() }()
for scan.Scan() { for scan.Scan() {
sourceChan <- scan.Bytes()
}
}()
// Slows down the unmarshal operation to every 500ms
go func() {
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
stdout := ProgressTemplate{} stdout := ProgressTemplate{}
err := json.Unmarshal(scan.Bytes(), &stdout) err := json.Unmarshal(event, &stdout)
if err == nil { if err == nil {
p.Progress = DownloadProgress{ p.Progress = DownloadProgress{
Status: StatusDownloading, Status: StatusDownloading,
@@ -139,7 +155,7 @@ func (p *Process) Start() {
p.Url, stdout.Percentage, p.Url, stdout.Percentage,
) )
} }
} })
}() }()
// ------------- end progress block ------------- // // ------------- end progress block ------------- //
@@ -188,7 +204,7 @@ func (p *Process) Kill() error {
// Returns the available format for this URL // Returns the available format for this URL
func (p *Process) GetFormatsSync() (DownloadFormats, error) { func (p *Process) GetFormatsSync() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, p.Url, "-J") cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output() stdout, err := cmd.Output()
if err != nil { if err != nil {
@@ -241,7 +257,7 @@ func (p *Process) SetPending() {
} }
func (p *Process) SetMetadata() error { func (p *Process) SetMetadata() error {
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, p.Url, "-J") cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()

View File

@@ -13,7 +13,7 @@ import (
func Authenticated(next http.Handler) http.Handler { func Authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !config.Instance().GetConfig().RequireAuth { if !config.Instance().RequireAuth {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }

View File

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

View File

@@ -1,9 +1,10 @@
package rest package rest
import ( import (
"encoding/json"
"net/http" "net/http"
"github.com/goccy/go-json" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
@@ -19,7 +20,7 @@ func (h *Handler) Exec() http.HandlerFunc {
req := internal.DownloadRequest{} req := internal.DownloadRequest{}
if err := json.NewDecoder(r.Body).DecodeContext(r.Context(), &req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
@@ -29,7 +30,7 @@ func (h *Handler) Exec() http.HandlerFunc {
return return
} }
err = json.NewEncoder(w).EncodeContext(r.Context(), id) err = json.NewEncoder(w).Encode(id)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
@@ -48,7 +49,7 @@ func (h *Handler) Running() http.HandlerFunc {
return return
} }
err = json.NewEncoder(w).EncodeContext(r.Context(), res) err = json.NewEncoder(w).Encode(res)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
@@ -63,7 +64,7 @@ func (h *Handler) SetCookies() http.HandlerFunc {
req := new(internal.SetCookiesRequest) req := new(internal.SetCookiesRequest)
err := json.NewDecoder(r.Body).DecodeContext(r.Context(), req) err := json.NewDecoder(r.Body).Decode(req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -75,7 +76,79 @@ func (h *Handler) SetCookies() http.HandlerFunc {
return return
} }
err = json.NewEncoder(w).EncodeContext(r.Context(), "ok") err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }

View File

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

View File

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

View File

@@ -2,47 +2,27 @@ package rx
import "time" import "time"
/*
Package rx contains:
- Definitions for common reactive programming functions/patterns
*/
// ReactiveX inspired debounce function.
//
// Debounce emits a string from the source channel only after a particular
// time span determined a Go Interval
//
// --A--B--CD--EFG-------|>
//
// -t-> |>
// -t-> |> t is a timer tick
// -t-> |>
//
// --A-----C-----G-------|>
func Debounce(interval time.Duration, source chan string, f func(emit string)) {
var item string
timer := time.NewTimer(interval)
for {
select {
case item = <-source:
timer.Reset(interval)
case <-timer.C:
if item != "" {
f(item)
}
}
}
}
// ReactiveX inspired sample function. // ReactiveX inspired sample function.
// //
// Debounce emits the most recently emitted value from the source // Debounce emits the most recently emitted value from the source
// withing the timespan set by the span time.Duration // withing the timespan set by the span time.Duration
func Sample[T any](span time.Duration, source chan T, cb func(emit T)) { func Sample(span time.Duration, source chan []byte, done chan struct{}, fn func(e []byte)) {
timer := time.NewTimer(span) var (
item []byte
ticker = time.NewTicker(span)
)
for { for {
<-timer.C select {
cb(<-source) case <-ticker.C:
timer.Reset(span) if item != nil {
fn(item)
}
case <-source:
item = <-source
case <-done:
ticker.Stop()
return
}
} }
} }

View File

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

View File

@@ -14,7 +14,7 @@ import (
// FreeSpace gets the available Bytes writable to download directory // FreeSpace gets the available Bytes writable to download directory
func FreeSpace() (uint64, error) { func FreeSpace() (uint64, error) {
var stat unix.Statfs_t var stat unix.Statfs_t
unix.Statfs(config.Instance().GetConfig().DownloadPath, &stat) unix.Statfs(config.Instance().DownloadPath, &stat)
return (stat.Bavail * uint64(stat.Bsize)), nil return (stat.Bavail * uint64(stat.Bsize)), nil
} }
@@ -26,7 +26,7 @@ func DirectoryTree() (*[]string, error) {
children []Node children []Node
} }
rootPath := config.Instance().GetConfig().DownloadPath rootPath := config.Instance().DownloadPath
stack := internal.NewStack[Node]() stack := internal.NewStack[Node]()
flattened := make([]string, 0) flattened := make([]string, 0)

View File

@@ -8,7 +8,7 @@ import (
// Update using the builtin function of yt-dlp // Update using the builtin function of yt-dlp
func UpdateExecutable() error { func UpdateExecutable() error {
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, "-U") cmd := exec.Command(config.Instance().DownloaderPath, "-U")
err := cmd.Start() err := cmd.Start()
if err != nil { if err != nil {