templates editor #62
This commit is contained in:
@@ -27,4 +27,4 @@ COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
|||||||
ENV JWT_SECRET=secret
|
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" ]
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { atom } from 'recoil'
|
import { atom, selector } from 'recoil'
|
||||||
|
import { CustomTemplate } from '../types'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { serverURL } from './settings'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { getOrElse } from 'fp-ts/lib/Either'
|
||||||
|
|
||||||
export const downloadTemplateState = atom({
|
export const cookiesTemplateState = atom({
|
||||||
key: 'downloadTemplateState',
|
key: 'cookiesTemplateState',
|
||||||
default: localStorage.getItem('lastDownloadTemplate') ?? '',
|
default: localStorage.getItem('cookiesTemplate') ?? '',
|
||||||
effects: [
|
effects: [
|
||||||
({ onSet }) => onSet(e => localStorage.setItem('lastDownloadTemplate', e))
|
({ onSet }) => onSet(e => localStorage.setItem('cookiesTemplate', e))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const customArgsState = atom({
|
||||||
|
key: 'customArgsState',
|
||||||
|
default: localStorage.getItem('customArgs') ?? '',
|
||||||
|
effects: [
|
||||||
|
({ onSet }) => onSet(e => localStorage.setItem('customArgs', e))
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -15,3 +28,25 @@ export const filenameTemplateState = atom({
|
|||||||
({ onSet }) => onSet(e => localStorage.setItem('lastFilenameTemplate', e))
|
({ onSet }) => onSet(e => localStorage.setItem('lastFilenameTemplate', e))
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const downloadTemplateState = selector({
|
||||||
|
key: 'downloadTemplateState',
|
||||||
|
get: ({ get }) =>
|
||||||
|
`${get(customArgsState)} ${get(cookiesTemplateState)}`
|
||||||
|
.replace(/ +/g, ' ')
|
||||||
|
.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const savedTemplatesState = selector<CustomTemplate[]>({
|
||||||
|
key: 'savedTemplatesState',
|
||||||
|
get: async ({ get }) => {
|
||||||
|
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
return pipe(
|
||||||
|
either,
|
||||||
|
getOrElse(() => new Array<CustomTemplate>())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
dangerouslyAllowMutability: true
|
||||||
|
})
|
||||||
@@ -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}`)
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { FileUpload } from '@mui/icons-material'
|
import { FileUpload } from '@mui/icons-material'
|
||||||
import CloseIcon from '@mui/icons-material/Close'
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
import {
|
import {
|
||||||
|
Autocomplete,
|
||||||
Backdrop,
|
Backdrop,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Container,
|
Container,
|
||||||
@@ -10,10 +12,7 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
|
||||||
TextField
|
TextField
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import AppBar from '@mui/material/AppBar'
|
import AppBar from '@mui/material/AppBar'
|
||||||
@@ -30,7 +29,7 @@ import {
|
|||||||
useTransition
|
useTransition
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil'
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
import { downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate'
|
import { customArgsState, downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate'
|
||||||
import { settingsState } from '../atoms/settings'
|
import { settingsState } from '../atoms/settings'
|
||||||
import { availableDownloadPathsState, connectedState } from '../atoms/status'
|
import { availableDownloadPathsState, connectedState } from '../atoms/status'
|
||||||
import FormatsGrid from '../components/FormatsGrid'
|
import FormatsGrid from '../components/FormatsGrid'
|
||||||
@@ -39,6 +38,7 @@ import { useRPC } from '../hooks/useRPC'
|
|||||||
import { CliArguments } from '../lib/argsParser'
|
import { CliArguments } from '../lib/argsParser'
|
||||||
import type { DLMetadata } from '../types'
|
import type { DLMetadata } from '../types'
|
||||||
import { isValidURL, toFormatArgs } from '../utils'
|
import { isValidURL, toFormatArgs } from '../utils'
|
||||||
|
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
||||||
|
|
||||||
const Transition = forwardRef(function Transition(
|
const Transition = forwardRef(function Transition(
|
||||||
props: TransitionProps & {
|
props: TransitionProps & {
|
||||||
@@ -60,19 +60,18 @@ export default function DownloadDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onDownloadStart
|
onDownloadStart
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// recoil state
|
|
||||||
const settings = useRecoilValue(settingsState)
|
const settings = useRecoilValue(settingsState)
|
||||||
const isConnected = useRecoilValue(connectedState)
|
const isConnected = useRecoilValue(connectedState)
|
||||||
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
|
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
|
||||||
|
const downloadTemplate = useRecoilValue(downloadTemplateState)
|
||||||
|
|
||||||
// ephemeral state
|
|
||||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
||||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||||
|
|
||||||
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState)
|
const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
|
||||||
const [downloadPath, setDownloadPath] = useState(0)
|
const [downloadPath, setDownloadPath] = useState('')
|
||||||
|
|
||||||
const [filenameTemplate, setFilenameTemplate] = useRecoilState(
|
const [filenameTemplate, setFilenameTemplate] = useRecoilState(
|
||||||
filenameTemplateState
|
filenameTemplateState
|
||||||
@@ -83,20 +82,16 @@ export default function DownloadDialog({
|
|||||||
|
|
||||||
const [isPlaylist, setIsPlaylist] = useState(false)
|
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||||
|
|
||||||
// memos
|
|
||||||
const cliArgs = useMemo(() =>
|
const cliArgs = useMemo(() =>
|
||||||
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
|
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
|
||||||
)
|
)
|
||||||
|
|
||||||
// context
|
|
||||||
const { i18n } = useI18n()
|
const { i18n } = useI18n()
|
||||||
const { client } = useRPC()
|
const { client } = useRPC()
|
||||||
|
|
||||||
// refs
|
|
||||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||||
const customFilenameInputRef = useRef<HTMLInputElement>(null)
|
const customFilenameInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// transitions
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,13 +103,13 @@ export default function DownloadDialog({
|
|||||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
||||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
||||||
|
|
||||||
client.download(
|
client.download({
|
||||||
immediate || url || workingUrl,
|
url: immediate || url || workingUrl,
|
||||||
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
|
args: `${cliArgs.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||||
availableDownloadPaths[downloadPath] ?? '',
|
pathOverride: downloadPath ?? '',
|
||||||
filenameTemplate,
|
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
||||||
isPlaylist,
|
playlist: isPlaylist,
|
||||||
)
|
})
|
||||||
|
|
||||||
setUrl('')
|
setUrl('')
|
||||||
setWorkingUrl('')
|
setWorkingUrl('')
|
||||||
@@ -177,7 +172,6 @@ export default function DownloadDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
<Dialog
|
<Dialog
|
||||||
fullScreen
|
fullScreen
|
||||||
open={open}
|
open={open}
|
||||||
@@ -203,10 +197,15 @@ export default function DownloadDialog({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
<Box sx={{
|
||||||
|
backgroundColor: (theme) => theme.palette.background.default,
|
||||||
|
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||||
|
}}>
|
||||||
<Container sx={{ my: 4 }} >
|
<Container sx={{ my: 4 }} >
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Paper
|
<Paper
|
||||||
|
elevation={4}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: 2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -267,8 +266,9 @@ export default function DownloadDialog({
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
settings.fileRenaming &&
|
settings.fileRenaming &&
|
||||||
<Grid item xs={8}>
|
<Grid item xs={settings.pathOverriding ? 8 : 12}>
|
||||||
<TextField
|
<TextField
|
||||||
|
sx={{ mt: 1 }}
|
||||||
ref={customFilenameInputRef}
|
ref={customFilenameInputRef}
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n.t('customFilename')}
|
label={i18n.t('customFilename')}
|
||||||
@@ -286,22 +286,30 @@ export default function DownloadDialog({
|
|||||||
settings.pathOverriding &&
|
settings.pathOverriding &&
|
||||||
<Grid item xs={4}>
|
<Grid item xs={4}>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>{i18n.t('customPath')}</InputLabel>
|
<Autocomplete
|
||||||
<Select
|
disablePortal
|
||||||
label={i18n.t('customPath')}
|
options={availableDownloadPaths.map((dir) => ({ label: dir, dir }))}
|
||||||
defaultValue={0}
|
autoHighlight
|
||||||
variant={'outlined'}
|
getOptionLabel={(option) => option.label}
|
||||||
value={downloadPath}
|
onChange={(_, value) => {
|
||||||
onChange={(e) => setDownloadPath(Number(e.target.value))}
|
setDownloadPath(value?.dir!)
|
||||||
>
|
}}
|
||||||
{availableDownloadPaths.map((val: string, idx: number) => (
|
renderOption={(props, option) => (
|
||||||
<MenuItem key={idx} value={idx}>{val}</MenuItem>
|
<Box
|
||||||
))}
|
component="li"
|
||||||
</Select>
|
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
|
||||||
|
{...props}>
|
||||||
|
{option.label}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
sx={{ width: '100%', mt: 1 }}
|
||||||
|
renderInput={(params) => <TextField {...params} label={i18n.t('customPath')} />}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<ExtraDownloadOptions />
|
||||||
<Grid container spacing={1} pt={2} justifyContent="space-between">
|
<Grid container spacing={1} pt={2} justifyContent="space-between">
|
||||||
<Grid item>
|
<Grid item>
|
||||||
<Button
|
<Button
|
||||||
@@ -357,7 +365,7 @@ export default function DownloadDialog({
|
|||||||
pickedAudioFormat={pickedAudioFormat}
|
pickedAudioFormat={pickedAudioFormat}
|
||||||
/>}
|
/>}
|
||||||
</Container>
|
</Container>
|
||||||
|
</Box>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
37
frontend/src/components/ExtraDownloadOptions.tsx
Normal file
37
frontend/src/components/ExtraDownloadOptions.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
|
||||||
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
|
||||||
|
const ExtraDownloadOptions: React.FC = () => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
const customTemplates = useRecoilValue(savedTemplatesState)
|
||||||
|
const [, setCustomArgs] = useRecoilState(customArgsState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Autocomplete
|
||||||
|
disablePortal
|
||||||
|
options={customTemplates.map(({ name, content }) => ({ label: name, content }))}
|
||||||
|
autoHighlight
|
||||||
|
getOptionLabel={(option) => option.label}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
setCustomArgs(value?.content!)
|
||||||
|
}}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<Box
|
||||||
|
component="li"
|
||||||
|
sx={{ mr: 2, flexShrink: 0 }}
|
||||||
|
{...props}>
|
||||||
|
{option.label}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
sx={{ width: '100%', mt: 2 }}
|
||||||
|
renderInput={(params) => <TextField {...params} label={i18n.t('savedTemplates')} />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExtraDownloadOptions
|
||||||
@@ -4,30 +4,38 @@ import { loadingAtom } from '../atoms/ui'
|
|||||||
import DownloadDialog from './DownloadDialog'
|
import 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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as O from 'fp-ts/Option'
|
import * as O from 'fp-ts/Option'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil'
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
import { share, take, timer } from 'rxjs'
|
import { share, take, timer } from 'rxjs'
|
||||||
import { downloadsState } from '../atoms/downloads'
|
import { downloadsState } from '../atoms/downloads'
|
||||||
@@ -14,7 +14,7 @@ import { datetimeCompareFunc, isRPCResponse } from '../utils'
|
|||||||
interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
|
interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
|
||||||
|
|
||||||
const SocketSubscriber: React.FC<Props> = ({ children }) => {
|
const SocketSubscriber: React.FC<Props> = ({ children }) => {
|
||||||
const [, setIsConnected] = useRecoilState(connectedState)
|
const [connected, setIsConnected] = useRecoilState(connectedState)
|
||||||
const [, setDownloads] = useRecoilState(downloadsState)
|
const [, setDownloads] = useRecoilState(downloadsState)
|
||||||
|
|
||||||
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
|
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
|
||||||
@@ -62,7 +62,11 @@ const SocketSubscriber: React.FC<Props> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
useSubscription(timer(0, 1000), () => client.running())
|
useEffect(() => {
|
||||||
|
if (connected) {
|
||||||
|
timer(0, 1000).subscribe(() => client.running())
|
||||||
|
}
|
||||||
|
}, [connected])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>{children}</>
|
<>{children}</>
|
||||||
|
|||||||
226
frontend/src/components/TemplatesEditor.tsx
Normal file
226
frontend/src/components/TemplatesEditor.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Backdrop,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Slide,
|
||||||
|
TextField,
|
||||||
|
Toolbar,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
|
import { matchW } from 'fp-ts/lib/Either'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { CustomTemplate } from '../types'
|
||||||
|
|
||||||
|
const Transition = forwardRef(function Transition(
|
||||||
|
props: TransitionProps & {
|
||||||
|
children: React.ReactElement
|
||||||
|
},
|
||||||
|
ref: React.Ref<unknown>,
|
||||||
|
) {
|
||||||
|
return <Slide direction="up" ref={ref} {...props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Props extends React.HTMLAttributes<HTMLBaseElement> {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [templateName, setTemplateName] = useState('')
|
||||||
|
const [templateContent, setTemplateContent] = useState('')
|
||||||
|
|
||||||
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState<CustomTemplate[]>([])
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
getTemplates()
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const getTemplates = async () => {
|
||||||
|
const task = ffetch<CustomTemplate[]>(`${serverAddr}/api/v1/template/all`)
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l),
|
||||||
|
(r) => setTemplates(r)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTemplate = async () => {
|
||||||
|
const task = ffetch<unknown>(`${serverAddr}/api/v1/template`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: templateName,
|
||||||
|
content: templateContent,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'warning'),
|
||||||
|
() => {
|
||||||
|
pushMessage('Added template')
|
||||||
|
getTemplates()
|
||||||
|
setTemplateName('')
|
||||||
|
setTemplateContent('')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTemplate = async (id: string) => {
|
||||||
|
const task = ffetch<unknown>(`${serverAddr}/api/v1/template/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
const either = await task()
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
either,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'warning'),
|
||||||
|
() => {
|
||||||
|
pushMessage('Deleted template')
|
||||||
|
getTemplates()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullScreen
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
TransitionComponent={Transition}
|
||||||
|
>
|
||||||
|
<Backdrop
|
||||||
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||||
|
open={isPending}
|
||||||
|
/>
|
||||||
|
<AppBar sx={{ position: 'relative' }}>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||||
|
{i18n.t('templatesEditor')}
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box sx={{
|
||||||
|
backgroundColor: (theme) => theme.palette.background.default,
|
||||||
|
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||||
|
}}>
|
||||||
|
<Grid container spacing={2} sx={{ p: 4 }}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container spacing={2} justifyContent="center" alignItems="center">
|
||||||
|
<Grid item xs={3}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('templatesEditorNameLabel')}
|
||||||
|
onChange={e => setTemplateName(e.currentTarget.value)}
|
||||||
|
value={templateName}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={9}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('templatesEditorContentLabel')}
|
||||||
|
onChange={e => setTemplateContent(e.currentTarget.value)}
|
||||||
|
value={templateContent}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <Button
|
||||||
|
variant='contained'
|
||||||
|
onClick={() => startTransition(() => { addTemplate() })}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</Button>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
{templates.map(template => (
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
spacing={2}
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
key={template.id}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Grid item xs={3}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('templatesEditorNameLabel')}
|
||||||
|
value={template.name}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={9}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('templatesEditorContentLabel')}
|
||||||
|
value={template.content}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <Button
|
||||||
|
variant='contained'
|
||||||
|
onClick={() => {
|
||||||
|
startTransition(() => { deleteTemplate(template.id) })
|
||||||
|
}}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</Button>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Dialog >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemplatesEditor
|
||||||
@@ -3,6 +3,14 @@ import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
|
|||||||
|
|
||||||
import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
|
import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
|
||||||
|
|
||||||
|
type DownloadRequestArgs = {
|
||||||
|
url: string,
|
||||||
|
args: string,
|
||||||
|
pathOverride?: string,
|
||||||
|
renameTo?: string,
|
||||||
|
playlist?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export class RPCClient {
|
export class RPCClient {
|
||||||
private seq: number
|
private seq: number
|
||||||
private httpEndpoint: string
|
private httpEndpoint: string
|
||||||
@@ -33,6 +41,7 @@ export class RPCClient {
|
|||||||
return args
|
return args
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
|
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendHTTP<T>(req: RPCRequest) {
|
private async sendHTTP<T>(req: RPCRequest) {
|
||||||
@@ -48,34 +57,45 @@ export class RPCClient {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
public download(
|
public download(req: DownloadRequestArgs) {
|
||||||
url: string,
|
if (!req.url) {
|
||||||
args: string,
|
|
||||||
pathOverride = '',
|
|
||||||
renameTo = '',
|
|
||||||
playlist?: boolean
|
|
||||||
) {
|
|
||||||
if (!url) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (playlist) {
|
|
||||||
|
const rename = req.args.includes('-o')
|
||||||
|
? req.args
|
||||||
|
.substring(req.args.indexOf('-o'))
|
||||||
|
.replaceAll("'", '')
|
||||||
|
.replaceAll('"', '')
|
||||||
|
.split('-o')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.join('')
|
||||||
|
.split(' ')
|
||||||
|
.at(0) ?? ''
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const sanitizedArgs = this.argsSanitizer(
|
||||||
|
req.args.replace('-o', '').replace(rename, '')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (req.playlist) {
|
||||||
return this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.ExecPlaylist',
|
method: 'Service.ExecPlaylist',
|
||||||
params: [{
|
params: [{
|
||||||
URL: url,
|
URL: req.url,
|
||||||
Params: this.argsSanitizer(args),
|
Params: sanitizedArgs,
|
||||||
Path: pathOverride,
|
Path: req.pathOverride,
|
||||||
Rename: renameTo,
|
Rename: req.renameTo || rename,
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.sendHTTP({
|
this.sendHTTP({
|
||||||
method: 'Service.Exec',
|
method: 'Service.Exec',
|
||||||
params: [{
|
params: [{
|
||||||
URL: url.split('?list').at(0)!,
|
URL: req.url.split('?list').at(0)!,
|
||||||
Params: this.argsSanitizer(args),
|
Params: sanitizedArgs,
|
||||||
Path: pathOverride,
|
Path: req.pathOverride,
|
||||||
Rename: renameTo,
|
Rename: req.renameTo || rename,
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ const Toaster: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toasts.length > 0) {
|
if (toasts.length > 0) {
|
||||||
|
|
||||||
const closer = setInterval(() => {
|
const closer = setInterval(() => {
|
||||||
setToasts(t => t.map(t => ({ ...t, open: deletePredicate(t) })))
|
setToasts(t => t.map(t => ({ ...t, open: deletePredicate(t) })))
|
||||||
}, 2000)
|
}, 900)
|
||||||
|
|
||||||
const cleaner = setInterval(() => {
|
const cleaner = setInterval(() => {
|
||||||
setToasts(t => t.filter(deletePredicate))
|
setToasts(t => t.filter(deletePredicate))
|
||||||
}, 1000)
|
}, 2005)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(closer)
|
clearInterval(closer)
|
||||||
|
|||||||
@@ -83,3 +83,8 @@ export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
|
|||||||
|
|
||||||
export type PlayRequest = Pick<DirectoryEntry, 'path'>
|
export type PlayRequest = Pick<DirectoryEntry, 'path'>
|
||||||
|
|
||||||
|
export type CustomTemplate = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
20
go.mod
20
go.mod
@@ -12,3 +12,23 @@ require (
|
|||||||
golang.org/x/sys v0.13.0
|
golang.org/x/sys v0.13.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/mod v0.3.0 // indirect
|
||||||
|
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
|
modernc.org/cc/v3 v3.40.0 // indirect
|
||||||
|
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||||
|
modernc.org/libc v1.24.1 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.6.0 // indirect
|
||||||
|
modernc.org/opt v0.1.3 // indirect
|
||||||
|
modernc.org/sqlite v1.26.0 // indirect
|
||||||
|
modernc.org/strutil v1.1.3 // indirect
|
||||||
|
modernc.org/token v1.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
54
go.sum
54
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||||
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
@@ -8,11 +10,63 @@ github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
|||||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
|
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
||||||
|
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
|
||||||
|
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||||
|
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
|
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||||
|
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||||
|
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||||
|
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||||
|
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||||
|
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
|
||||||
|
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
|
||||||
|
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
|
||||||
|
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||||
|
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||||
|
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||||
|
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
4
main.go
4
main.go
@@ -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")
|
||||||
@@ -74,5 +76,5 @@ func main() {
|
|||||||
log.Println(cli.BgRed, "config", cli.Reset, "no config file found")
|
log.Println(cli.BgRed, "config", cli.Reset, "no config file found")
|
||||||
}
|
}
|
||||||
|
|
||||||
server.RunBlocking(port, frontend)
|
server.RunBlocking(port, frontend, localDatabasePath)
|
||||||
}
|
}
|
||||||
|
|||||||
26
server/dbutils/migrate.go
Normal file
26
server/dbutils/migrate.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package dbutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AutoMigrate(ctx context.Context, db *sql.DB) error {
|
||||||
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`CREATE TABLE IF NOT EXISTS templates (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
content TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -78,3 +78,9 @@ type DownloadRequest struct {
|
|||||||
type SetCookiesRequest struct {
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,3 +82,75 @@ func (h *Handler) SetCookies() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AddTemplate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
req := new(internal.CustomTemplate)
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Content == "" {
|
||||||
|
http.Error(w, "Invalid template", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.service.SaveTemplate(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewEncoder(w).Encode("ok")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetTemplates() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
templates, err := h.service.GetTemplates(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewEncoder(w).Encode(templates)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteTemplate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
err := h.service.DeleteTemplate(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewEncoder(w).Encode("ok")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user