Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae4a5da3d | ||
| 13cc89fe3b | |||
| 32844bbe3e | |||
| e69829fcef | |||
| d6c0646756 | |||
|
|
1df308f388 | ||
|
|
6a11249fbc | ||
| 78c1559e84 | |||
|
|
cfd6b78695 | ||
| 5d97873748 | |||
| 58b05e1403 | |||
| 985629fd2e | |||
| cafaf2707e | |||
|
|
823a725df4 | ||
| 40b25ed385 | |||
| 8632d313c3 | |||
|
|
98f794c822 | ||
|
|
c2a02bb0b7 | ||
| f19718d46c |
@@ -27,5 +27,7 @@ RUN apk update && \
|
||||
|
||||
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
||||
|
||||
ENV JWT_SECRET=secret
|
||||
|
||||
EXPOSE 3033
|
||||
CMD [ "./yt-dlp-webui" , "--out", "/downloads" ]
|
||||
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads" ]
|
||||
|
||||
45
README.md
45
README.md
@@ -12,15 +12,23 @@ The bottleneck remains yt-dlp startup time.
|
||||
**Docker images are available on [Docker Hub](https://hub.docker.com/r/marcobaobao/yt-dlp-webui) or [ghcr.io](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui)**.
|
||||
|
||||
```sh
|
||||
docker pull marcobaobao/yt-dlp-webui:latest
|
||||
docker pull marcobaobao/yt-dlp-webui
|
||||
```
|
||||
```sh
|
||||
# latest stable
|
||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
||||
# latest dev version
|
||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
### Integrated File browser
|
||||
Stream or download your content, easily.
|
||||
|
||||

|
||||
|
||||
## Changelog
|
||||
```
|
||||
05/03/22: Korean translation by kimpig
|
||||
@@ -88,14 +96,8 @@ Future releases will have:
|
||||
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
|
||||
```sh
|
||||
# recomended for ARM and x86 devices
|
||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
||||
# or
|
||||
# docker pull marcobaobao/yt-dlp-webui:latest
|
||||
docker run -d -p 3033:3033 -v <your dir>:/downloads ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
||||
|
||||
# or even
|
||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
||||
docker create --name yt-dlp-webui -p 8082:3033 -v <your dir>:/downloads ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
||||
docker pull marcobaobao/yt-dlp-webui
|
||||
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
|
||||
```
|
||||
|
||||
Or with docker but building the container manually.
|
||||
@@ -105,6 +107,17 @@ docker build -t yt-dlp-webui .
|
||||
docker run -d -p 3033:3033 -v <your dir>:/downloads yt-dlp-webui
|
||||
```
|
||||
|
||||
If you opt to add RPC authentication...
|
||||
```sh
|
||||
docker run -d \
|
||||
-p 3033:3033 \
|
||||
-e JWT_SECRET randomsecret
|
||||
-v /path/to/downloads:/downloads \
|
||||
marcobaobao/yt-dlp-webui \
|
||||
--auth \
|
||||
--secret your_rpc_secret
|
||||
```
|
||||
|
||||
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
|
||||
|
||||
```sh
|
||||
@@ -132,6 +145,10 @@ The config file **will overwrite what have been passed as cli argument**.
|
||||
port: 8989
|
||||
downloadPath: /home/ren/archive
|
||||
downloaderPath: /usr/local/bin/yt-dlp
|
||||
|
||||
# Optional settings
|
||||
require_auth: true
|
||||
rpc_secret: my_random_secret
|
||||
```
|
||||
|
||||
### Systemd integration
|
||||
@@ -187,16 +204,10 @@ For more information open an issue on GitHub and I will provide more info ASAP.
|
||||
|
||||
## FAQ
|
||||
- **Will it availabe for Raspberry Pi/ generic ARM devices?**
|
||||
- Yes, it's currently available through ghcr.io
|
||||
```
|
||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
|
||||
```
|
||||
- Yes, it's cross platform :)
|
||||
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
|
||||
- **Why the docker image is so heavy?**
|
||||
- Originally it was 1.8GB circa, now it has been slimmed to ~340MB compressed. This is due to the fact that it encapsule a basic Alpine linux image + FFmpeg + Node.js + Python3 + yt-dlp.
|
||||
|
||||
- **Why is it so slow to start a download?**
|
||||
- I genuinely don't know. I know that standalone yt-dlp is slow to start up even on my M1 Mac, so....
|
||||
|
||||
- **Update**: Since Golang migration and Multi-Stage builds the Docker image is now 75MB circa. A reduction of over 400% in size :D.
|
||||
## What yt-dlp-webui is not
|
||||
`yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@mui/icons-material": "^5.11.16",
|
||||
"@mui/material": "^5.13.2",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"fp-ts": "^2.16.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.0.5",
|
||||
|
||||
@@ -1,208 +1,12 @@
|
||||
import { ThemeProvider } from '@emotion/react'
|
||||
|
||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||
import Dashboard from '@mui/icons-material/Dashboard'
|
||||
import Menu from '@mui/icons-material/Menu'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
||||
import Storage from '@mui/icons-material/Storage'
|
||||
|
||||
import { Box, createTheme } from '@mui/material'
|
||||
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
import { grey } from '@mui/material/colors'
|
||||
|
||||
import { Suspense, lazy, useMemo, useState } from 'react'
|
||||
import { Provider, useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
|
||||
import { RootState, store } from './stores/store'
|
||||
|
||||
import AppBar from './components/AppBar'
|
||||
import Drawer from './components/Drawer'
|
||||
|
||||
import Archive from './Archive'
|
||||
import { formatGiB } from './utils'
|
||||
|
||||
function AppContent() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
|
||||
const mode = settings.theme
|
||||
const theme = useMemo(() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: settings.theme,
|
||||
background: {
|
||||
default: settings.theme === 'light' ? grey[50] : '#121212'
|
||||
},
|
||||
},
|
||||
}), [settings.theme]
|
||||
)
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const Home = lazy(() => import('./Home'))
|
||||
const Settings = lazy(() => import('./Settings'))
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position="absolute" open={open}>
|
||||
<Toolbar sx={{ pr: '24px' }}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={toggleDrawer}
|
||||
sx={{
|
||||
marginRight: '36px',
|
||||
...(open && { display: 'none' }),
|
||||
}}
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h6"
|
||||
color="inherit"
|
||||
noWrap
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
yt-dlp WebUI
|
||||
</Typography>
|
||||
{
|
||||
status.freeSpace ?
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<Storage />
|
||||
<span> {formatGiB(status.freeSpace)} </span>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<SettingsEthernet />
|
||||
<span> {status.connected ? settings.serverAddr : 'not connected'}</span>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer variant="permanent" open={open}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
px: [1],
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={toggleDrawer}>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List component="nav">
|
||||
<Link to={'/'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton disabled={status.downloading}>
|
||||
<ListItemIcon>
|
||||
<Dashboard />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Home" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/archive'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton disabled={status.downloading}>
|
||||
<ListItemIcon>
|
||||
<DownloadIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Archive" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/settings'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton disabled={status.downloading}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Settings />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/archive" element={
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Archive />
|
||||
</Suspense>
|
||||
} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
import { Provider } from 'react-redux'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { router } from './router'
|
||||
import { store } from './stores/store'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AppContent />
|
||||
<RouterProvider router={router} />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
import { FileUpload } from '@mui/icons-material'
|
||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||
import {
|
||||
Alert,
|
||||
Backdrop,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Snackbar,
|
||||
SpeedDial,
|
||||
SpeedDialAction,
|
||||
SpeedDialIcon,
|
||||
styled,
|
||||
TextField
|
||||
} from '@mui/material'
|
||||
import { Buffer } from 'buffer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { DownloadsCardView } from './components/DownloadsCardView'
|
||||
import { DownloadsListView } from './components/DownloadsListView'
|
||||
import FormatsGrid from './components/FormatsGrid'
|
||||
import { CliArguments } from './features/core/argsParser'
|
||||
import I18nBuilder from './features/core/intl'
|
||||
import { RPCClient, socket$ } from './features/core/rpcClient'
|
||||
import { toggleListView } from './features/settings/settingsSlice'
|
||||
import { connected, setFreeSpace } from './features/status/statusSlice'
|
||||
import { RootState } from './stores/store'
|
||||
import type { DLMetadata, RPCResponse, RPCResult } from './types'
|
||||
import { isValidURL, toFormatArgs } from './utils'
|
||||
|
||||
export default function Home() {
|
||||
// redux state
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// ephemeral state
|
||||
const [activeDownloads, setActiveDownloads] = useState<Array<RPCResult>>()
|
||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||
|
||||
const [customArgs, setCustomArgs] = useState('')
|
||||
const [downloadPath, setDownloadPath] = useState(0)
|
||||
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([])
|
||||
|
||||
const [fileNameOverride, setFilenameOverride] = useState('')
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [workingUrl, setWorkingUrl] = useState('')
|
||||
|
||||
const [showBackdrop, setShowBackdrop] = useState(true)
|
||||
const [showToast, setShowToast] = useState(true)
|
||||
|
||||
const [socketHasError, setSocketHasError] = useState(false)
|
||||
|
||||
// memos
|
||||
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
|
||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
||||
|
||||
// refs
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
const customFilenameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/* -------------------- Effects -------------------- */
|
||||
|
||||
/* WebSocket connect event handler*/
|
||||
useEffect(() => {
|
||||
if (!status.connected) {
|
||||
const sub = socket$.subscribe({
|
||||
next: () => {
|
||||
dispatch(connected())
|
||||
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
|
||||
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
|
||||
},
|
||||
error: () => {
|
||||
setSocketHasError(true)
|
||||
setShowBackdrop(false)
|
||||
},
|
||||
complete: () => {
|
||||
setSocketHasError(true)
|
||||
setShowBackdrop(false)
|
||||
},
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}
|
||||
}, [socket$, status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
if (status.connected) {
|
||||
client.running()
|
||||
const interval = setInterval(() => client.running(), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
client.freeSpace().then(bytes => dispatch(setFreeSpace(bytes.result)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (status.connected) {
|
||||
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
|
||||
switch (typeof event.result) {
|
||||
case 'object':
|
||||
setActiveDownloads(
|
||||
(event.result ?? [])
|
||||
.filter((r) => !!r.info.url)
|
||||
.sort((a, b) => a.info.title.localeCompare(b.info.title))
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}
|
||||
}, [socket$, status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDownloads && activeDownloads.length >= 0) {
|
||||
setShowBackdrop(false)
|
||||
}
|
||||
}, [activeDownloads?.length])
|
||||
|
||||
useEffect(() => {
|
||||
client.directoryTree()
|
||||
.then(data => {
|
||||
setAvailableDownloadPaths(data.result)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/* -------------------- callbacks-------------------- */
|
||||
|
||||
/**
|
||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||
*/
|
||||
const sendUrl = (immediate?: string) => {
|
||||
const codes = new Array<string>();
|
||||
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat);
|
||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat);
|
||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat);
|
||||
|
||||
client.download(
|
||||
immediate || url || workingUrl,
|
||||
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
|
||||
availableDownloadPaths[downloadPath] ?? '',
|
||||
fileNameOverride
|
||||
)
|
||||
|
||||
setUrl('')
|
||||
setWorkingUrl('')
|
||||
setShowBackdrop(true)
|
||||
|
||||
setTimeout(() => {
|
||||
resetInput()
|
||||
setShowBackdrop(true)
|
||||
setDownloadFormats(undefined)
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrive url from input and display the formats selection view
|
||||
*/
|
||||
const sendUrlFormatSelection = () => {
|
||||
setWorkingUrl(url)
|
||||
setUrl('')
|
||||
setPickedAudioFormat('')
|
||||
setPickedVideoFormat('')
|
||||
setPickedBestFormat('')
|
||||
|
||||
setShowBackdrop(true)
|
||||
|
||||
client.formats(url)
|
||||
?.then(formats => {
|
||||
setDownloadFormats(formats.result)
|
||||
setShowBackdrop(false)
|
||||
resetInput()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the url state whenever the input value changes
|
||||
* @param e Input change event
|
||||
*/
|
||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(e.target.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the filename override state whenever the input value changes
|
||||
* @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>) => {
|
||||
setCustomArgs(e.target.value)
|
||||
localStorage.setItem("last-input-args", e.target.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a specific download if id's provided, other wise abort all running ones.
|
||||
* @param id The download id / pid
|
||||
* @returns void
|
||||
*/
|
||||
const abort = (id?: string) => {
|
||||
if (id) {
|
||||
client.kill(id)
|
||||
return
|
||||
}
|
||||
client.killAll()
|
||||
}
|
||||
|
||||
const parseUrlListFile = (event: any) => {
|
||||
const urlList = event.target.files
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', $event => {
|
||||
const base64 = $event.target?.result!.toString().split(',')[1]
|
||||
Buffer.from(base64!, 'base64')
|
||||
.toString()
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.filter(_url => isValidURL(_url))
|
||||
.forEach(_url => sendUrl(_url))
|
||||
})
|
||||
reader.readAsDataURL(urlList[0])
|
||||
}
|
||||
|
||||
const resetInput = () => {
|
||||
urlInputRef.current!.value = '';
|
||||
if (customFilenameInputRef.current) {
|
||||
customFilenameInputRef.current!.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- styled components -------------------- */
|
||||
|
||||
const Input = styled('input')({
|
||||
display: 'none',
|
||||
});
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={showBackdrop}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Backdrop>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Grid container>
|
||||
<TextField
|
||||
fullWidth
|
||||
ref={urlInputRef}
|
||||
label={i18n.t('urlInput')}
|
||||
variant="outlined"
|
||||
onChange={handleUrlChange}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<label htmlFor="icon-button-file">
|
||||
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} />
|
||||
<IconButton color="primary" aria-label="upload file" component="span">
|
||||
<FileUpload />
|
||||
</IconButton>
|
||||
</label>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||
{
|
||||
settings.enableCustomArgs &&
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={i18n.t('customArgsInput')}
|
||||
variant="outlined"
|
||||
onChange={handleCustomArgsChange}
|
||||
value={customArgs}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.fileRenaming &&
|
||||
<Grid item xs={8}>
|
||||
<TextField
|
||||
ref={customFilenameInputRef}
|
||||
fullWidth
|
||||
label={i18n.t('customFilename')}
|
||||
variant="outlined"
|
||||
value={fileNameOverride}
|
||||
onChange={handleFilenameOverrideChange}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.pathOverriding &&
|
||||
<Grid item xs={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{i18n.t('customPath')}</InputLabel>
|
||||
<Select
|
||||
label={i18n.t('customPath')}
|
||||
defaultValue={0}
|
||||
variant={'outlined'}
|
||||
value={downloadPath}
|
||||
onChange={(e) => setDownloadPath(Number(e.target.value))}
|
||||
>
|
||||
{availableDownloadPaths.map((val: string, idx: number) => (
|
||||
<MenuItem key={idx} value={idx}>{val}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
}
|
||||
</Grid>
|
||||
<Grid container spacing={1} pt={2}>
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={url === ''}
|
||||
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
|
||||
>
|
||||
{settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => abort()}
|
||||
>
|
||||
{i18n.t('abortAllButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid >
|
||||
{/* Format Selection grid */}
|
||||
{downloadFormats && <FormatsGrid
|
||||
downloadFormats={downloadFormats}
|
||||
onBestQualitySelected={(id) => {
|
||||
setPickedBestFormat(id)
|
||||
setPickedVideoFormat('')
|
||||
setPickedAudioFormat('')
|
||||
}}
|
||||
onVideoSelected={(id) => {
|
||||
setPickedVideoFormat(id)
|
||||
setPickedBestFormat('')
|
||||
}}
|
||||
onAudioSelected={(id) => {
|
||||
setPickedAudioFormat(id)
|
||||
setPickedBestFormat('')
|
||||
}}
|
||||
onClear={() => {
|
||||
setPickedAudioFormat('');
|
||||
setPickedVideoFormat('');
|
||||
setPickedBestFormat('');
|
||||
}}
|
||||
onSubmit={sendUrl}
|
||||
pickedBestFormat={pickedBestFormat}
|
||||
pickedVideoFormat={pickedVideoFormat}
|
||||
pickedAudioFormat={pickedAudioFormat}
|
||||
/>}
|
||||
{
|
||||
settings.listView ?
|
||||
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
|
||||
<DownloadsCardView downloads={activeDownloads ?? []} abortFunction={abort} />
|
||||
}
|
||||
<Snackbar
|
||||
open={showToast === status.connected}
|
||||
autoHideDuration={1500}
|
||||
onClose={() => setShowToast(false)}
|
||||
>
|
||||
<Alert variant="filled" severity="success">
|
||||
{`Connected to (${settings.serverAddr}:${settings.serverPort})`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar open={socketHasError}>
|
||||
<Alert variant="filled" severity="error">
|
||||
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<SpeedDial
|
||||
ariaLabel="SpeedDial basic example"
|
||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||
icon={<SpeedDialIcon />}
|
||||
>
|
||||
<SpeedDialAction
|
||||
icon={<FormatListBulleted />}
|
||||
tooltipTitle={`Table view`}
|
||||
tooltipOpen
|
||||
onClick={() => dispatch(toggleListView())}
|
||||
/>
|
||||
</SpeedDial>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
181
frontend/src/Layout.tsx
Normal file
181
frontend/src/Layout.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ThemeProvider } from '@emotion/react'
|
||||
|
||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||
import Dashboard from '@mui/icons-material/Dashboard'
|
||||
import Menu from '@mui/icons-material/Menu'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
||||
import Storage from '@mui/icons-material/Storage'
|
||||
|
||||
import { Box, createTheme } from '@mui/material'
|
||||
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { grey } from '@mui/material/colors'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { RootState } from './stores/store'
|
||||
|
||||
import AppBar from './components/AppBar'
|
||||
import Drawer from './components/Drawer'
|
||||
|
||||
import Logout from './components/Logout'
|
||||
import { formatGiB } from './utils'
|
||||
import ThemeToggler from './components/ThemeToggler'
|
||||
|
||||
export default function Layout() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
|
||||
const mode = settings.theme
|
||||
const theme = useMemo(() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: settings.theme,
|
||||
background: {
|
||||
default: settings.theme === 'light' ? grey[50] : '#121212'
|
||||
},
|
||||
},
|
||||
}), [settings.theme]
|
||||
)
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(state => !state)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position="absolute" open={open}>
|
||||
<Toolbar sx={{ pr: '24px' }}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={toggleDrawer}
|
||||
sx={{
|
||||
marginRight: '36px',
|
||||
...(open && { display: 'none' }),
|
||||
}}
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h6"
|
||||
color="inherit"
|
||||
noWrap
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
yt-dlp WebUI
|
||||
</Typography>
|
||||
{
|
||||
status.freeSpace ?
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<Storage />
|
||||
<span> {formatGiB(status.freeSpace)} </span>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<SettingsEthernet />
|
||||
<span> {status.connected ? settings.serverAddr : 'not connected'}</span>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer variant="permanent" open={open}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
px: [1],
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={toggleDrawer}>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List component="nav">
|
||||
<Link to={'/'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton disabled={status.downloading}>
|
||||
<ListItemIcon>
|
||||
<Dashboard />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Home" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/archive'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton disabled={status.downloading}>
|
||||
<ListItemIcon>
|
||||
<DownloadIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Archive" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/settings'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton disabled={status.downloading}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<ThemeToggler />
|
||||
<Logout />
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@@ -86,33 +86,33 @@ languages:
|
||||
customArgsInput: 自定义 yt-dlp 参数
|
||||
rpcConnErr: Error while conencting to RPC server
|
||||
spanish:
|
||||
urlInput: YouTube or other supported service video url
|
||||
statusTitle: Status
|
||||
startButton: Start
|
||||
statusReady: Ready
|
||||
abortAllButton: Abort All
|
||||
updateBinButton: Update yt-dlp binary
|
||||
darkThemeButton: Dark theme
|
||||
lightThemeButton: Light theme
|
||||
settingsAnchor: Settings
|
||||
serverAddressTitle: Server address
|
||||
serverPortTitle: Port
|
||||
extractAudioCheckbox: Extract audio
|
||||
noMTimeCheckbox: Don't set file modification time
|
||||
bgReminder: Once you close this page the download will continue in the background.
|
||||
toastConnected: 'Connected to '
|
||||
toastUpdated: Updated yt-dlp binary!
|
||||
formatSelectionEnabler: Enable video/audio formats selection
|
||||
themeSelect: 'Theme'
|
||||
languageSelect: 'Language'
|
||||
overridesAnchor: Overrides
|
||||
pathOverrideOption: Enable output path overriding
|
||||
filenameOverrideOption: Enable output file name overriding
|
||||
customFilename: Custom filemame (leave blank to use default)
|
||||
customPath: Custom path
|
||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
||||
customArgsInput: Custom yt-dlp arguments
|
||||
rpcConnErr: Error while conencting to RPC server
|
||||
urlInput: URL de YouTube u otro servicio compatible
|
||||
statusTitle: Estado
|
||||
startButton: Iniciar
|
||||
statusReady: Listo
|
||||
abortAllButton: Cancelar Todo
|
||||
updateBinButton: Actualizar el binario yt-dlp
|
||||
darkThemeButton: Tema oscuro
|
||||
lightThemeButton: Tema claro
|
||||
settingsAnchor: Ajustes
|
||||
serverAddressTitle: Dirección del servidor
|
||||
serverPortTitle: Puerto
|
||||
extractAudioCheckbox: Extraer audio
|
||||
noMTimeCheckbox: No guardar el tiempo de modificación del archivo
|
||||
bgReminder: Si cierras esta página, la descarga continuará en segundo plano.
|
||||
toastConnected: 'Conectado a'
|
||||
toastUpdated: ¡El binario yt-dlp está actualizado!
|
||||
formatSelectionEnabler: Habilitar la selección de formatos de video/audio
|
||||
themeSelect: 'Tema'
|
||||
languageSelect: 'Idiomas'
|
||||
overridesAnchor: Anulaciones
|
||||
pathOverrideOption: Sobreescribir en la ruta de salida
|
||||
filenameOverrideOption: Sobreescribir el nombre del fichero
|
||||
customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
|
||||
customPath: Ruta personalizada
|
||||
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
||||
customArgsInput: Argumentos yt-dlp personalizados
|
||||
rpcConnErr: Error al conectarse al servidor RPC
|
||||
russian:
|
||||
urlInput: YouTube or other supported service video url
|
||||
statusTitle: Status
|
||||
@@ -198,3 +198,31 @@ languages:
|
||||
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
||||
customArgsInput: yt-dlpのオプション
|
||||
rpcConnErr: Error while conencting to RPC server
|
||||
catalan:
|
||||
urlInput: URL de YouTube o d'un altre servei compatible
|
||||
statusTitle: Estat
|
||||
startButton: Iniciar
|
||||
statusReady: Llest
|
||||
abortAllButton: Cancel·lar Tot
|
||||
updateBinButton: Actualitzar el binari yt-dlp
|
||||
darkThemeButton: Tema fosc
|
||||
lightThemeButton: Tema clar
|
||||
settingsAnchor: Configuració
|
||||
serverAddressTitle: Direcció del servidor
|
||||
serverPortTitle: Port
|
||||
extractAudioCheckbox: Extreure àudio
|
||||
noMTimeCheckbox: No guardar el temps de modificació de l'arxiu
|
||||
bgReminder: Si tanques aquesta pàgina, la descàrrega continuarà en segon pla.
|
||||
toastConnected: 'Connectat a'
|
||||
toastUpdated: El binari yt-dlp està actualitzat!
|
||||
formatSelectionEnabler: Habilitar la selecció de formats de vídeo/àudio
|
||||
themeSelect: 'Tema'
|
||||
languageSelect: 'Idiomes'
|
||||
overridesAnchor: Anul·lacions
|
||||
pathOverrideOption: Sobreescriure en la ruta de sortida
|
||||
filenameOverrideOption: Sobreescriure el nom del fitxer
|
||||
customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat)
|
||||
customPath: Ruta personalitzada
|
||||
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
|
||||
customArgsInput: Arguments yt-dlp personalitzats
|
||||
rpcConnErr: Error en connectar-se al servidor RPC
|
||||
|
||||
335
frontend/src/components/DownloadDialog.tsx
Normal file
335
frontend/src/components/DownloadDialog.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { FileUpload } from '@mui/icons-material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
styled,
|
||||
TextField
|
||||
} from '@mui/material'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Slide from '@mui/material/Slide'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { Buffer } from 'buffer'
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import FormatsGrid from '../components/FormatsGrid'
|
||||
import { CliArguments } from '../lib/argsParser'
|
||||
import I18nBuilder from '../lib/intl'
|
||||
import { RPCClient } from '../lib/rpcClient'
|
||||
import { RootState } from '../stores/store'
|
||||
import type { DLMetadata } from '../types'
|
||||
import { isValidURL, toFormatArgs } from '../utils'
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
children: React.ReactElement
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
return <Slide direction="up" ref={ref} {...props} />
|
||||
})
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function DownloadDialog({ open, onClose }: Props) {
|
||||
// redux state
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// ephemeral state
|
||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||
|
||||
const [customArgs, setCustomArgs] = useState('')
|
||||
const [downloadPath, setDownloadPath] = useState(0)
|
||||
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([])
|
||||
|
||||
const [fileNameOverride, setFilenameOverride] = useState('')
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [workingUrl, setWorkingUrl] = useState('')
|
||||
|
||||
// memos
|
||||
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
|
||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
||||
|
||||
// refs
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
const customFilenameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// effects
|
||||
useEffect(() => {
|
||||
client.directoryTree()
|
||||
.then(data => {
|
||||
setAvailableDownloadPaths(data.result)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
|
||||
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||
*/
|
||||
const sendUrl = (immediate?: string) => {
|
||||
const codes = new Array<string>()
|
||||
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat)
|
||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
||||
|
||||
client.download(
|
||||
immediate || url || workingUrl,
|
||||
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
|
||||
availableDownloadPaths[downloadPath] ?? '',
|
||||
fileNameOverride
|
||||
)
|
||||
|
||||
setUrl('')
|
||||
setWorkingUrl('')
|
||||
|
||||
setTimeout(() => {
|
||||
resetInput()
|
||||
setDownloadFormats(undefined)
|
||||
onClose()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrive url from input and display the formats selection view
|
||||
*/
|
||||
const sendUrlFormatSelection = () => {
|
||||
setWorkingUrl(url)
|
||||
setUrl('')
|
||||
setPickedAudioFormat('')
|
||||
setPickedVideoFormat('')
|
||||
setPickedBestFormat('')
|
||||
|
||||
client.formats(url)
|
||||
?.then(formats => {
|
||||
setDownloadFormats(formats.result)
|
||||
resetInput()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the url state whenever the input value changes
|
||||
* @param e Input change event
|
||||
*/
|
||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(e.target.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the filename override state whenever the input value changes
|
||||
* @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>) => {
|
||||
setCustomArgs(e.target.value)
|
||||
localStorage.setItem("last-input-args", e.target.value)
|
||||
}
|
||||
|
||||
const parseUrlListFile = (event: any) => {
|
||||
const urlList = event.target.files
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', $event => {
|
||||
const base64 = $event.target?.result!.toString().split(',')[1]
|
||||
Buffer.from(base64!, 'base64')
|
||||
.toString()
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.filter(_url => isValidURL(_url))
|
||||
.forEach(_url => sendUrl(_url))
|
||||
})
|
||||
reader.readAsDataURL(urlList[0])
|
||||
}
|
||||
|
||||
const resetInput = () => {
|
||||
urlInputRef.current!.value = ''
|
||||
if (customFilenameInputRef.current) {
|
||||
customFilenameInputRef.current!.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- styled components -------------------- */
|
||||
|
||||
const Input = styled('input')({
|
||||
display: 'none',
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<AppBar sx={{ position: 'relative' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={onClose}
|
||||
aria-label="close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
Download
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Grid container>
|
||||
<TextField
|
||||
fullWidth
|
||||
ref={urlInputRef}
|
||||
label={i18n.t('urlInput')}
|
||||
variant="outlined"
|
||||
onChange={handleUrlChange}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<label htmlFor="icon-button-file">
|
||||
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} />
|
||||
<IconButton color="primary" aria-label="upload file" component="span">
|
||||
<FileUpload />
|
||||
</IconButton>
|
||||
</label>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||
{
|
||||
settings.enableCustomArgs &&
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={i18n.t('customArgsInput')}
|
||||
variant="outlined"
|
||||
onChange={handleCustomArgsChange}
|
||||
value={customArgs}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.fileRenaming &&
|
||||
<Grid item xs={8}>
|
||||
<TextField
|
||||
ref={customFilenameInputRef}
|
||||
fullWidth
|
||||
label={i18n.t('customFilename')}
|
||||
variant="outlined"
|
||||
value={fileNameOverride}
|
||||
onChange={handleFilenameOverrideChange}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.pathOverriding &&
|
||||
<Grid item xs={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{i18n.t('customPath')}</InputLabel>
|
||||
<Select
|
||||
label={i18n.t('customPath')}
|
||||
defaultValue={0}
|
||||
variant={'outlined'}
|
||||
value={downloadPath}
|
||||
onChange={(e) => setDownloadPath(Number(e.target.value))}
|
||||
>
|
||||
{availableDownloadPaths.map((val: string, idx: number) => (
|
||||
<MenuItem key={idx} value={idx}>{val}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
}
|
||||
</Grid>
|
||||
<Grid container spacing={1} pt={2}>
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={url === ''}
|
||||
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
|
||||
>
|
||||
{settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid >
|
||||
{/* Format Selection grid */}
|
||||
{downloadFormats && <FormatsGrid
|
||||
downloadFormats={downloadFormats}
|
||||
onBestQualitySelected={(id) => {
|
||||
setPickedBestFormat(id)
|
||||
setPickedVideoFormat('')
|
||||
setPickedAudioFormat('')
|
||||
}}
|
||||
onVideoSelected={(id) => {
|
||||
setPickedVideoFormat(id)
|
||||
setPickedBestFormat('')
|
||||
}}
|
||||
onAudioSelected={(id) => {
|
||||
setPickedAudioFormat(id)
|
||||
setPickedBestFormat('')
|
||||
}}
|
||||
onClear={() => {
|
||||
setPickedAudioFormat('')
|
||||
setPickedVideoFormat('')
|
||||
setPickedBestFormat('')
|
||||
}}
|
||||
onSubmit={sendUrl}
|
||||
pickedBestFormat={pickedBestFormat}
|
||||
pickedVideoFormat={pickedVideoFormat}
|
||||
pickedAudioFormat={pickedAudioFormat}
|
||||
/>}
|
||||
</Container>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function DownloadsListView({ downloads, abortFunction }: Props) {
|
||||
return (
|
||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||
<Grid item xs={12}>
|
||||
<TableContainer component={Paper} sx={{ minHeight: '65vh' }} elevation={2}>
|
||||
<TableContainer component={Paper} sx={{ minHeight: '80vh' }} elevation={2}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
|
||||
24
frontend/src/components/Logout.tsx
Normal file
24
frontend/src/components/Logout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
import { getHttpEndpoint } from '../utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function Logout() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const logout = async () => {
|
||||
const res = await fetch(`${getHttpEndpoint()}/auth/logout`)
|
||||
if (res.ok) {
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItemButton onClick={logout}>
|
||||
<ListItemIcon>
|
||||
<LogoutIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="RPC authentication" />
|
||||
</ListItemButton>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/Splash.tsx
Normal file
34
frontend/src/components/Splash.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||
|
||||
const FlexContainer = styled(Container)({
|
||||
display: 'flex',
|
||||
minWidth: '100%',
|
||||
minHeight: '80vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
})
|
||||
|
||||
const Title = styled(Typography)({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: '0.5rem'
|
||||
})
|
||||
|
||||
export default function Splash() {
|
||||
return (
|
||||
<FlexContainer>
|
||||
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||
<CloudDownloadIcon />
|
||||
</SvgIcon>
|
||||
</Title>
|
||||
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||
No active downloads
|
||||
</Title>
|
||||
</FlexContainer>
|
||||
)
|
||||
}
|
||||
27
frontend/src/components/ThemeToggler.tsx
Normal file
27
frontend/src/components/ThemeToggler.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { setTheme } from '../features/settings/settingsSlice'
|
||||
import { RootState } from '../stores/store'
|
||||
import { Brightness4, Brightness5 } from '@mui/icons-material'
|
||||
|
||||
export default function ThemeToggler() {
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return (
|
||||
<ListItemButton onClick={() => {
|
||||
settings.theme === 'light'
|
||||
? dispatch(setTheme('dark'))
|
||||
: dispatch(setTheme('light'))
|
||||
}}>
|
||||
<ListItemIcon>
|
||||
{
|
||||
settings.theme === 'light'
|
||||
? <Brightness4 />
|
||||
: <Brightness5 />
|
||||
}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Toggle theme" />
|
||||
</ListItemButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export interface FormatSelectionState {
|
||||
bestFormat: string
|
||||
audioFormat: string
|
||||
videoFormat: string
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
|
||||
export type LanguageUnion = "english" | "chinese" | "russian" | "italian" | "spanish" | "korean" | "japanese"
|
||||
export type LanguageUnion = "english" | "chinese" | "russian" | "italian" | "spanish" | "korean" | "japanese" | "catalan"
|
||||
export type ThemeUnion = "light" | "dark"
|
||||
|
||||
export interface SettingsState {
|
||||
|
||||
@@ -3,8 +3,9 @@ import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
export class CliArguments {
|
||||
private _extractAudio: boolean;
|
||||
private _noMTime: boolean;
|
||||
private _proxy: string;
|
||||
private _extractAudio: boolean
|
||||
private _noMTime: boolean
|
||||
private _proxy: string
|
||||
|
||||
constructor(extractAudio = false, noMTime = false) {
|
||||
this._extractAudio = extractAudio;
|
||||
this._noMTime = noMTime;
|
||||
constructor(extractAudio = false, noMTime = true) {
|
||||
this._extractAudio = extractAudio
|
||||
this._noMTime = noMTime
|
||||
this._proxy = ""
|
||||
}
|
||||
|
||||
public get extractAudio(): boolean {
|
||||
return this._extractAudio;
|
||||
return this._extractAudio
|
||||
}
|
||||
|
||||
public toggleExtractAudio() {
|
||||
this._extractAudio = !this._extractAudio;
|
||||
return this;
|
||||
this._extractAudio = !this._extractAudio
|
||||
return this
|
||||
}
|
||||
|
||||
public disableExtractAudio() {
|
||||
this._extractAudio = false;
|
||||
return this;
|
||||
this._extractAudio = false
|
||||
return this
|
||||
}
|
||||
|
||||
public get noMTime(): boolean {
|
||||
return this._noMTime;
|
||||
return this._noMTime
|
||||
}
|
||||
|
||||
public toggleNoMTime() {
|
||||
this._noMTime = !this._noMTime;
|
||||
return this;
|
||||
this._noMTime = !this._noMTime
|
||||
return this
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
let args = '';
|
||||
let args = ''
|
||||
|
||||
if (this._extractAudio) {
|
||||
args += '-x '
|
||||
@@ -43,19 +43,19 @@ export class CliArguments {
|
||||
args += '--no-mtime '
|
||||
}
|
||||
|
||||
return args.trim();
|
||||
return args.trim()
|
||||
}
|
||||
|
||||
public fromString(str: string): CliArguments {
|
||||
if (str) {
|
||||
if (str.includes('-x')) {
|
||||
this._extractAudio = true;
|
||||
this._extractAudio = true
|
||||
}
|
||||
|
||||
if (str.includes('--no-mtime')) {
|
||||
this._noMTime = true;
|
||||
this._noMTime = true
|
||||
}
|
||||
}
|
||||
return this;
|
||||
return this
|
||||
}
|
||||
}
|
||||
21
frontend/src/lib/httpClient.ts
Normal file
21
frontend/src/lib/httpClient.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as E from 'fp-ts/Either'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
type FetchInit = {
|
||||
url: string,
|
||||
opt?: RequestInit
|
||||
}
|
||||
|
||||
export async function ffetch<T>(
|
||||
url: string,
|
||||
onSuccess: (res: T) => void,
|
||||
onError: (err: string) => void,
|
||||
opt?: RequestInit,
|
||||
) {
|
||||
const res = await fetch(url, opt)
|
||||
if (!res.ok) {
|
||||
onError(await res.text())
|
||||
return
|
||||
}
|
||||
onSuccess(await res.json() as T)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import i18n from "../../assets/i18n.yaml"
|
||||
import i18n from "../assets/i18n.yaml"
|
||||
|
||||
export default class I18nBuilder {
|
||||
private language: string
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DLMetadata, RPCRequest, RPCResponse } from '../../types'
|
||||
import type { DLMetadata, RPCRequest, RPCResponse } from '../types'
|
||||
|
||||
import { webSocket } from 'rxjs/webSocket'
|
||||
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../../utils'
|
||||
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../utils'
|
||||
|
||||
export const socket$ = webSocket<any>(getWebSocketEndpoint())
|
||||
|
||||
50
frontend/src/router.tsx
Normal file
50
frontend/src/router.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { createBrowserRouter } from 'react-router-dom'
|
||||
import Layout from './Layout'
|
||||
|
||||
const Home = lazy(() => import('./views/Home'))
|
||||
const Login = lazy(() => import('./views/Login'))
|
||||
const Archive = lazy(() => import('./views/Archive'))
|
||||
const Settings = lazy(() => import('./views/Settings'))
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
Component: () => <Layout />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Home />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Settings />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/archive',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Archive />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Login />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
]
|
||||
},
|
||||
])
|
||||
42
frontend/src/types/index.d.ts
vendored
42
frontend/src/types/index.d.ts
vendored
@@ -21,23 +21,28 @@ export type RPCResponse<T> = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
type DownloadInfo = {
|
||||
url: string
|
||||
filesize_approx?: number
|
||||
resolution?: string
|
||||
thumbnail: string
|
||||
title: string
|
||||
vcodec?: string
|
||||
acodec?: string
|
||||
ext?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type DownloadProgress = {
|
||||
speed: number
|
||||
eta: number
|
||||
percentage: string
|
||||
}
|
||||
|
||||
export type RPCResult = {
|
||||
id: string
|
||||
progress: {
|
||||
speed: number
|
||||
eta: number
|
||||
percentage: string
|
||||
}
|
||||
info: {
|
||||
url: string
|
||||
filesize_approx?: number
|
||||
resolution?: string
|
||||
thumbnail: string
|
||||
title: string
|
||||
vcodec?: string
|
||||
acodec?: string
|
||||
ext?: string
|
||||
}
|
||||
progress: DownloadProgress
|
||||
info: DownloadInfo
|
||||
}
|
||||
|
||||
export type RPCParams = {
|
||||
@@ -65,11 +70,14 @@ export type DLFormat = {
|
||||
export type DirectoryEntry = {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
shaSum: string
|
||||
modTime: string
|
||||
isVideo: boolean
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
export type DeleteRequest = Omit<DirectoryEntry, 'name' | 'isDirectory'>
|
||||
export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
|
||||
|
||||
export type PlayRequest = Omit<DirectoryEntry, 'shaSum' | 'name' | 'isDirectory'>
|
||||
export type PlayRequest = Pick<DirectoryEntry, 'path'>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* @returns ip validity test
|
||||
*/
|
||||
export function validateIP(ipAddr: string): boolean {
|
||||
let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm
|
||||
return ipRegex.test(ipAddr)
|
||||
let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm
|
||||
return ipRegex.test(ipAddr)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,8 +18,8 @@ export function validateIP(ipAddr: string): boolean {
|
||||
* @returns domain validity test
|
||||
*/
|
||||
export function validateDomain(domainName: string): boolean {
|
||||
let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/
|
||||
return domainRegex.test(domainName) || domainName === 'localhost'
|
||||
let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/
|
||||
return domainRegex.test(domainName) || domainName === 'localhost'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,15 +34,15 @@ export function validateDomain(domainName: string): boolean {
|
||||
* @returns url validity test
|
||||
*/
|
||||
export function isValidURL(url: string): boolean {
|
||||
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
|
||||
return urlRegex.test(url)
|
||||
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
|
||||
return urlRegex.test(url)
|
||||
}
|
||||
|
||||
export function ellipsis(str: string, lim: number): string {
|
||||
if (str) {
|
||||
return str.length > lim ? `${str.substring(0, lim)}...` : str
|
||||
}
|
||||
return ''
|
||||
if (str) {
|
||||
return str.length > lim ? `${str.substring(0, lim)}...` : str
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,44 +51,46 @@ export function ellipsis(str: string, lim: number): string {
|
||||
* @returns download speed in KiB/s
|
||||
*/
|
||||
export function detectSpeed(str: string): number {
|
||||
let effective = str.match(/[\d,]+(\.\d+)?/)![0]
|
||||
const unit = str.replace(effective, '')
|
||||
switch (unit) {
|
||||
case 'MiB/s':
|
||||
return Number(effective) * 1000
|
||||
case 'KiB/s':
|
||||
return Number(effective)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
let effective = str.match(/[\d,]+(\.\d+)?/)![0]
|
||||
const unit = str.replace(effective, '')
|
||||
switch (unit) {
|
||||
case 'MiB/s':
|
||||
return Number(effective) * 1000
|
||||
case 'KiB/s':
|
||||
return Number(effective)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function toFormatArgs(codes: string[]): string {
|
||||
if (codes.length > 1) {
|
||||
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
||||
}
|
||||
if (codes.length === 1) {
|
||||
return ` -f ${codes[0]}`;
|
||||
}
|
||||
return '';
|
||||
if (codes.length > 1) {
|
||||
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
||||
}
|
||||
if (codes.length === 1) {
|
||||
return ` -f ${codes[0]}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getWebSocketEndpoint() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/ws-rpc`
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/ws`
|
||||
}
|
||||
|
||||
export function getHttpRPCEndpoint() {
|
||||
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/http-rpc`
|
||||
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/http`
|
||||
}
|
||||
|
||||
export function getHttpEndpoint() {
|
||||
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}`
|
||||
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}`
|
||||
}
|
||||
|
||||
export function formatGiB(bytes: number) {
|
||||
return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
||||
return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
||||
}
|
||||
|
||||
export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
|
||||
export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
|
||||
|
||||
export const dateTimeComparatorFunc = (a: string, b: string) => new Date(a).getTime() - new Date(b).getTime()
|
||||
|
||||
@@ -22,18 +22,24 @@ import {
|
||||
} from '@mui/material'
|
||||
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||
import FolderIcon from '@mui/icons-material/Folder'
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||
|
||||
import { Buffer } from 'buffer'
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
||||
import { useObservable } from './hooks/observable'
|
||||
import { RootState } from './stores/store'
|
||||
import { DeleteRequest, DirectoryEntry } from './types'
|
||||
import { useObservable } from '../hooks/observable'
|
||||
import { RootState } from '../stores/store'
|
||||
import { DeleteRequest, DirectoryEntry } from '../types'
|
||||
import { roundMiB } from '../utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
|
||||
export default function Downloaded() {
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
|
||||
@@ -45,37 +51,52 @@ export default function Downloaded() {
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const fetcher = () => fetch(`${serverAddr}/downloaded`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ subdir: '' })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => files$.next(data))
|
||||
|
||||
const fetcherSubfolder = (sub: string) => {
|
||||
const folders = sub.split('/')
|
||||
|
||||
let subdir = folders.length > 2
|
||||
? folders.slice(-(folders.length - 1)).join('/')
|
||||
: folders.pop()
|
||||
|
||||
fetch(`${serverAddr}/downloaded`, {
|
||||
const fetcher = () => ffetch<DirectoryEntry[]>(
|
||||
`${serverAddr}/archive/downloaded`,
|
||||
(d) => files$.next(d),
|
||||
() => navigate('/login'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ subdir: subdir })
|
||||
body: JSON.stringify({
|
||||
subdir: '',
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const fetcherSubfolder = (sub: string) => {
|
||||
const folders = sub.startsWith('/')
|
||||
? sub.substring(1).split('/')
|
||||
: sub.split('/')
|
||||
|
||||
const relpath = folders.length >= 2
|
||||
? folders.slice(-(folders.length - 1)).join('/')
|
||||
: folders.pop()
|
||||
|
||||
const _upperLevel = folders.slice(1, -1)
|
||||
const upperLevel = _upperLevel.length === 2
|
||||
? ['.', ..._upperLevel].join('/')
|
||||
: _upperLevel.join('/')
|
||||
|
||||
fetch(`${serverAddr}/archive/downloaded`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ subdir: relpath })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
files$.next([{
|
||||
isDirectory: true,
|
||||
name: '..',
|
||||
path: '',
|
||||
}, ...data])
|
||||
files$.next(sub
|
||||
? [{
|
||||
name: '..',
|
||||
isDirectory: true,
|
||||
path: upperLevel,
|
||||
}, ...data]
|
||||
: data
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,7 +120,7 @@ export default function Downloaded() {
|
||||
const deleteSelected = () => {
|
||||
Promise.all(selectable
|
||||
.filter(entry => entry.selected)
|
||||
.map(entry => fetch(`${serverAddr}/delete`, {
|
||||
.map(entry => fetch(`${serverAddr}/archive/delete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -118,7 +139,7 @@ export default function Downloaded() {
|
||||
|
||||
|
||||
const onFileClick = (path: string) => startTransition(() => {
|
||||
window.open(`${serverAddr}/play?path=${Buffer.from(path).toString('hex')}`)
|
||||
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`)
|
||||
})
|
||||
|
||||
const onFolderClick = (path: string) => startTransition(() => {
|
||||
@@ -138,7 +159,7 @@ export default function Downloaded() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<Typography pb={0} variant="h5" color="primary">
|
||||
<Typography py={1} variant="h5" color="primary">
|
||||
{'Archive'}
|
||||
</Typography>
|
||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||
@@ -147,11 +168,20 @@ export default function Downloaded() {
|
||||
<ListItem
|
||||
key={idx}
|
||||
secondaryAction={
|
||||
!file.isDirectory && <Checkbox
|
||||
edge="end"
|
||||
checked={file.selected}
|
||||
onChange={() => addSelected(file.name)}
|
||||
/>
|
||||
<div>
|
||||
{!file.isDirectory && <Typography
|
||||
variant="caption"
|
||||
component="span"
|
||||
>
|
||||
{roundMiB(file.size)}
|
||||
</Typography>
|
||||
}
|
||||
{!file.isDirectory && <Checkbox
|
||||
edge="end"
|
||||
checked={file.selected}
|
||||
onChange={() => addSelected(file.name)}
|
||||
/>}
|
||||
</div>
|
||||
}
|
||||
disablePadding
|
||||
>
|
||||
@@ -163,10 +193,15 @@ export default function Downloaded() {
|
||||
<ListItemIcon>
|
||||
{file.isDirectory
|
||||
? <FolderIcon />
|
||||
: <VideoFileIcon />
|
||||
: file.isVideo
|
||||
? <VideoFileIcon />
|
||||
: <InsertDriveFileIcon />
|
||||
}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={file.name} />
|
||||
<ListItemText
|
||||
primary={file.name}
|
||||
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
189
frontend/src/views/Home.tsx
Normal file
189
frontend/src/views/Home.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||
import {
|
||||
Alert,
|
||||
Backdrop,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Snackbar,
|
||||
SpeedDial,
|
||||
SpeedDialAction,
|
||||
SpeedDialIcon,
|
||||
styled
|
||||
} from '@mui/material'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import DownloadDialog from '../components/DownloadDialog'
|
||||
import { DownloadsCardView } from '../components/DownloadsCardView'
|
||||
import { DownloadsListView } from '../components/DownloadsListView'
|
||||
import Splash from '../components/Splash'
|
||||
import { toggleListView } from '../features/settings/settingsSlice'
|
||||
import { connected, setFreeSpace } from '../features/status/statusSlice'
|
||||
import I18nBuilder from '../lib/intl'
|
||||
import { RPCClient, socket$ } from '../lib/rpcClient'
|
||||
import { RootState } from '../stores/store'
|
||||
import type { RPCResponse, RPCResult } from '../types'
|
||||
import { dateTimeComparatorFunc } from '../utils'
|
||||
|
||||
export default function Home() {
|
||||
// redux state
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// ephemeral state
|
||||
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
|
||||
|
||||
const [showBackdrop, setShowBackdrop] = useState(true)
|
||||
const [showToast, setShowToast] = useState(true)
|
||||
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
const [socketHasError, setSocketHasError] = useState(false)
|
||||
|
||||
// memos
|
||||
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
|
||||
|
||||
/* -------------------- Effects -------------------- */
|
||||
|
||||
/* WebSocket connect event handler*/
|
||||
useEffect(() => {
|
||||
if (status.connected) { return }
|
||||
|
||||
const sub = socket$.subscribe({
|
||||
next: () => {
|
||||
dispatch(connected())
|
||||
},
|
||||
error: () => {
|
||||
setSocketHasError(true)
|
||||
setShowBackdrop(false)
|
||||
},
|
||||
complete: () => {
|
||||
setSocketHasError(true)
|
||||
setShowBackdrop(false)
|
||||
},
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}, [socket$, status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
if (status.connected) {
|
||||
client.running()
|
||||
const interval = setInterval(() => client.running(), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
client.freeSpace().then(bytes => dispatch(setFreeSpace(bytes.result)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status.connected) { return }
|
||||
|
||||
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
|
||||
switch (typeof event.result) {
|
||||
case 'object':
|
||||
setActiveDownloads(
|
||||
(event.result ?? [])
|
||||
.filter((r) => !!r.info.url)
|
||||
.sort((a, b) => dateTimeComparatorFunc(
|
||||
b.info.created_at,
|
||||
a.info.created_at,
|
||||
))
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}, [socket$, status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDownloads && activeDownloads.length >= 0) {
|
||||
setShowBackdrop(false)
|
||||
}
|
||||
}, [activeDownloads?.length])
|
||||
|
||||
/**
|
||||
* Abort a specific download if id's provided, other wise abort all running ones.
|
||||
* @param id The download id / pid
|
||||
* @returns void
|
||||
*/
|
||||
const abort = (id?: string) => {
|
||||
if (id) {
|
||||
client.kill(id)
|
||||
return
|
||||
}
|
||||
client.killAll()
|
||||
}
|
||||
|
||||
|
||||
/* -------------------- styled components -------------------- */
|
||||
|
||||
const Input = styled('input')({
|
||||
display: 'none',
|
||||
})
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={showBackdrop}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Backdrop>
|
||||
{activeDownloads?.length === 0 &&
|
||||
<Splash />
|
||||
}
|
||||
{
|
||||
settings.listView ?
|
||||
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
|
||||
<DownloadsCardView downloads={activeDownloads ?? []} abortFunction={abort} />
|
||||
}
|
||||
<Snackbar
|
||||
open={showToast === status.connected}
|
||||
autoHideDuration={1500}
|
||||
onClose={() => setShowToast(false)}
|
||||
>
|
||||
<Alert variant="filled" severity="success">
|
||||
{`Connected to (${settings.serverAddr}:${settings.serverPort})`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar open={socketHasError}>
|
||||
<Alert variant="filled" severity="error">
|
||||
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<SpeedDial
|
||||
ariaLabel="SpeedDial basic example"
|
||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||
icon={<SpeedDialIcon />}
|
||||
>
|
||||
<SpeedDialAction
|
||||
icon={<FormatListBulleted />}
|
||||
tooltipTitle={`Table view`}
|
||||
onClick={() => dispatch(toggleListView())}
|
||||
/>
|
||||
<SpeedDialAction
|
||||
icon={<DeleteForeverIcon />}
|
||||
tooltipTitle={i18n.t('abortAllButton')}
|
||||
onClick={() => abort()}
|
||||
/>
|
||||
<SpeedDialAction
|
||||
icon={<AddCircleIcon />}
|
||||
tooltipTitle={`New download`}
|
||||
onClick={() => setOpenDialog(true)}
|
||||
/>
|
||||
</SpeedDial>
|
||||
<DownloadDialog open={openDialog} onClose={() => {
|
||||
setOpenDialog(false)
|
||||
activeDownloads?.length === 0
|
||||
? setShowBackdrop(false)
|
||||
: setShowBackdrop(true)
|
||||
}} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
81
frontend/src/views/Login.tsx
Normal file
81
frontend/src/views/Login.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Login view component
|
||||
*/
|
||||
|
||||
import styled from '@emotion/styled'
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { getHttpEndpoint } from '../utils'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const LoginContainer = styled(Container)({
|
||||
display: 'flex',
|
||||
minWidth: '100%',
|
||||
minHeight: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})
|
||||
|
||||
const Title = styled(Typography)({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: '0.5rem'
|
||||
})
|
||||
|
||||
export default function Login() {
|
||||
const [secret, setSecret] = useState('')
|
||||
const [formHasError, setFormHasError] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const login = async () => {
|
||||
const res = await fetch(`${getHttpEndpoint()}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ secret })
|
||||
})
|
||||
res.ok ? navigate('/') : setFormHasError(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<LoginContainer>
|
||||
<Paper sx={{ padding: '1.5rem', minWidth: '25%' }}>
|
||||
<Stack direction="column" spacing={2}>
|
||||
<Title fontWeight={'700'} fontSize={32} color={'primary'}>
|
||||
yt-dlp WebUI
|
||||
</Title>
|
||||
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
||||
Authentication token will expire after 30 days.
|
||||
</Title>
|
||||
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
||||
In order to enable RPC authentication append the --auth
|
||||
<br />
|
||||
and --secret [secret] flags.
|
||||
</Title>
|
||||
<TextField
|
||||
id="outlined-password-input"
|
||||
label="RPC secret"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
error={formHasError}
|
||||
onChange={e => setSecret(e.currentTarget.value)}
|
||||
/>
|
||||
<Button variant="contained" size="large" onClick={() => login()}>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</LoginContainer>
|
||||
)
|
||||
}
|
||||
@@ -26,9 +26,9 @@ import {
|
||||
map,
|
||||
takeWhile
|
||||
} from 'rxjs'
|
||||
import { CliArguments } from './features/core/argsParser'
|
||||
import I18nBuilder from './features/core/intl'
|
||||
import { RPCClient } from './features/core/rpcClient'
|
||||
import { CliArguments } from '../lib/argsParser'
|
||||
import I18nBuilder from '../lib/intl'
|
||||
import { RPCClient } from '../lib/rpcClient'
|
||||
import {
|
||||
LanguageUnion,
|
||||
ThemeUnion,
|
||||
@@ -41,10 +41,10 @@ import {
|
||||
setServerAddr,
|
||||
setServerPort,
|
||||
setTheme
|
||||
} from './features/settings/settingsSlice'
|
||||
import { updated } from './features/status/statusSlice'
|
||||
import { RootState } from './stores/store'
|
||||
import { validateDomain, validateIP } from './utils'
|
||||
} from '../features/settings/settingsSlice'
|
||||
import { updated } from '../features/status/statusSlice'
|
||||
import { RootState } from '../stores/store'
|
||||
import { validateDomain, validateIP } from '../utils'
|
||||
|
||||
export default function Settings() {
|
||||
const dispatch = useDispatch()
|
||||
@@ -173,6 +173,7 @@ export default function Settings() {
|
||||
<MenuItem value="russian">Russian</MenuItem>
|
||||
<MenuItem value="korean">Korean</MenuItem>
|
||||
<MenuItem value="japanese">Japanese</MenuItem>
|
||||
<MenuItem value="catalan">Catalan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
17
go.mod
17
go.mod
@@ -1,23 +1,24 @@
|
||||
module github.com/marcopeocchi/yt-dlp-web-ui
|
||||
|
||||
go 1.19
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/goccy/go-json v0.10.2
|
||||
github.com/gofiber/fiber/v2 v2.43.0
|
||||
github.com/gofiber/websocket/v2 v2.1.5
|
||||
github.com/gofiber/fiber/v2 v2.47.0
|
||||
github.com/gofiber/websocket/v2 v2.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
|
||||
golang.org/x/sys v0.7.0
|
||||
golang.org/x/sys v0.9.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||
github.com/fasthttp/websocket v1.5.2 // indirect
|
||||
github.com/klauspost/compress v1.16.4 // indirect
|
||||
github.com/fasthttp/websocket v1.5.3 // indirect
|
||||
github.com/klauspost/compress v1.16.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/philhofer/fwd v1.1.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
@@ -25,6 +26,6 @@ require (
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
|
||||
github.com/tinylib/msgp v1.1.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.45.0 // indirect
|
||||
github.com/valyala/fasthttp v1.48.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
)
|
||||
|
||||
30
go.sum
30
go.sum
@@ -1,24 +1,26 @@
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/fasthttp/websocket v1.5.2 h1:KdCb0EpLpdJpfE3IPA5YLK/aYBO3dhZcvwxz6tXe2LQ=
|
||||
github.com/fasthttp/websocket v1.5.2/go.mod h1:S0KC1VBlx1SaXGXq7yi1wKz4jMub58qEnHQG9oHuqBw=
|
||||
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
|
||||
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
|
||||
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/gofiber/fiber/v2 v2.43.0 h1:yit3E4kHf178B60p5CQBa/3v+WVuziWMa/G2ZNyLJB0=
|
||||
github.com/gofiber/fiber/v2 v2.43.0/go.mod h1:mpS1ZNE5jU+u+BA4FbM+KKnUzJ4wzTK+FT2tG3tU+6I=
|
||||
github.com/gofiber/websocket/v2 v2.1.5 h1:2weAMr0Shb2ubhZ3+P4bkeWL+uCZ/NlgjSa1siEcvFM=
|
||||
github.com/gofiber/websocket/v2 v2.1.5/go.mod h1:BZZEk+XsjjF0V6/sAw00iGcB69dFb6Hb85ER9gr/xaU=
|
||||
github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs=
|
||||
github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU=
|
||||
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
|
||||
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
|
||||
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/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.16.4 h1:91KN02FnsOYhuunwU4ssRe8lc2JosWmizWa91B5v1PU=
|
||||
github.com/klauspost/compress v1.16.4/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
|
||||
github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
|
||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
|
||||
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||
@@ -37,8 +39,8 @@ github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
|
||||
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.45.0 h1:zPkkzpIn8tdHZUrVa6PzYd0i5verqiPSkgTd3bSUcpA=
|
||||
github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
||||
github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc=
|
||||
github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -70,8 +72,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||
|
||||
28
main.go
28
main.go
@@ -12,19 +12,26 @@ import (
|
||||
|
||||
var (
|
||||
port int
|
||||
configFile string
|
||||
downloadPath string
|
||||
downloaderPath string
|
||||
configFile string
|
||||
|
||||
requireAuth bool
|
||||
rpcSecret string
|
||||
//go:embed frontend/dist
|
||||
frontend embed.FS
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
|
||||
flag.StringVar(&downloadPath, "out", ".", "Directory where files will be saved")
|
||||
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
|
||||
|
||||
flag.StringVar(&configFile, "conf", "", "yt-dlp-WebUI config file path")
|
||||
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
|
||||
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
|
||||
|
||||
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
|
||||
flag.StringVar(&rpcSecret, "secret", "", "Secret required for auth")
|
||||
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
@@ -35,15 +42,18 @@ func main() {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
cfg := config.Instance()
|
||||
c := config.Instance()
|
||||
|
||||
c.SetPort(port)
|
||||
c.DownloadPath(downloadPath)
|
||||
c.DownloaderPath(downloaderPath)
|
||||
|
||||
c.RequireAuth(requireAuth)
|
||||
c.RPCSecret(rpcSecret)
|
||||
|
||||
if configFile != "" {
|
||||
cfg.LoadFromFile(configFile)
|
||||
c.LoadFromFile(configFile)
|
||||
}
|
||||
|
||||
cfg.SetPort(port)
|
||||
cfg.DownloadPath(downloadPath)
|
||||
cfg.DownloaderPath(downloaderPath)
|
||||
|
||||
server.RunBlocking(port, frontend)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var lock = &sync.Mutex{}
|
||||
var lock sync.Mutex
|
||||
|
||||
type serverConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
DownloadPath string `yaml:"downloadPath"`
|
||||
DownloaderPath string `yaml:"downloaderPath"`
|
||||
RequireAuth bool `yaml:"require_auth"`
|
||||
RPCSecret string `yaml:"rpc_secret"`
|
||||
}
|
||||
|
||||
type config struct {
|
||||
@@ -46,6 +48,13 @@ func (c *config) DownloaderPath(path string) {
|
||||
c.cfg.DownloaderPath = path
|
||||
}
|
||||
|
||||
func (c *config) RequireAuth(value bool) {
|
||||
c.cfg.RequireAuth = value
|
||||
}
|
||||
func (c *config) RPCSecret(secret string) {
|
||||
c.cfg.RPCSecret = secret
|
||||
}
|
||||
|
||||
var instance *config
|
||||
|
||||
func Instance() *config {
|
||||
|
||||
50
server/middleware/jwt.go
Normal file
50
server/middleware/jwt.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
)
|
||||
|
||||
const (
|
||||
TOKEN_COOKIE_NAME = "jwt"
|
||||
)
|
||||
|
||||
var Authenticated = func(c *fiber.Ctx) error {
|
||||
if !config.Instance().GetConfig().RequireAuth {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
cookie := c.Cookies(TOKEN_COOKIE_NAME)
|
||||
|
||||
if cookie == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("invalid token")
|
||||
}
|
||||
|
||||
token, _ := jwt.Parse(cookie, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||
})
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
|
||||
|
||||
if err != nil {
|
||||
return c.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("expired token")
|
||||
}
|
||||
} else {
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("invalid token")
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
@@ -116,7 +116,10 @@ func (p *Process) Start(path, filename string) {
|
||||
if err != nil {
|
||||
log.Println("Cannot retrieve info for", p.url)
|
||||
}
|
||||
info := DownloadInfo{URL: p.url}
|
||||
info := DownloadInfo{
|
||||
URL: p.url,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
json.Unmarshal(stdout, &info)
|
||||
p.mem.UpdateInfo(p.id, info)
|
||||
}()
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
TOKEN_COOKIE_NAME = "jwt"
|
||||
)
|
||||
|
||||
type DirectoryEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SHASum string `json:"shaSum"`
|
||||
IsDirectory bool `json:"isDirectory"`
|
||||
}
|
||||
|
||||
func isValidEntry(d fs.DirEntry) bool {
|
||||
return !strings.HasPrefix(d.Name(), ".") &&
|
||||
!strings.HasSuffix(d.Name(), ".part") &&
|
||||
!strings.HasSuffix(d.Name(), ".ytdl")
|
||||
}
|
||||
|
||||
func shaSumString(path string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(path))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
SHASum string `json:"shaSum"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
IsVideo bool `json:"isVideo"`
|
||||
IsDirectory bool `json:"isDirectory"`
|
||||
}
|
||||
|
||||
func walkDir(root string) (*[]DirectoryEntry, error) {
|
||||
@@ -42,17 +39,25 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
|
||||
}
|
||||
|
||||
for _, d := range dirs {
|
||||
if !isValidEntry(d) {
|
||||
if !utils.IsValidEntry(d) {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(root, d.Name())
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files = append(files, DirectoryEntry{
|
||||
Path: path,
|
||||
Name: d.Name(),
|
||||
SHASum: shaSumString(path),
|
||||
Size: info.Size(),
|
||||
SHASum: utils.ShaSumString(path),
|
||||
IsVideo: utils.IsVideo(d),
|
||||
IsDirectory: d.IsDir(),
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +65,8 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
SubDir string `json:"subdir"`
|
||||
SubDir string `json:"subdir"`
|
||||
OrderBy string `json:"orderBy"`
|
||||
}
|
||||
|
||||
func ListDownloaded(ctx *fiber.Ctx) error {
|
||||
@@ -77,6 +83,12 @@ func ListDownloaded(ctx *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if req.OrderBy == "modtime" {
|
||||
sort.SliceStable(*files, func(i, j int) bool {
|
||||
return (*files)[i].ModTime.After((*files)[j].ModTime)
|
||||
})
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
return ctx.JSON(files)
|
||||
}
|
||||
@@ -91,7 +103,7 @@ func DeleteFile(ctx *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
sum := shaSumString(req.Path)
|
||||
sum := utils.ShaSumString(req.Path)
|
||||
if sum != req.SHASum {
|
||||
return errors.New("shasum mismatch")
|
||||
}
|
||||
@@ -105,12 +117,8 @@ func DeleteFile(ctx *fiber.Ctx) error {
|
||||
return ctx.JSON("ok")
|
||||
}
|
||||
|
||||
type PlayRequest struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func PlayFile(ctx *fiber.Ctx) error {
|
||||
path := ctx.Query("path")
|
||||
func SendFile(ctx *fiber.Ctx) error {
|
||||
path := ctx.Params("id")
|
||||
|
||||
if path == "" {
|
||||
return errors.New("inexistent path")
|
||||
@@ -120,16 +128,68 @@ func PlayFile(ctx *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decodedStr := string(decoded)
|
||||
|
||||
root := config.Instance().GetConfig().DownloadPath
|
||||
|
||||
//TODO: further path / file validations
|
||||
|
||||
if strings.Contains(filepath.Dir(string(decoded)), root) {
|
||||
ctx.SendStatus(fiber.StatusPartialContent)
|
||||
return ctx.SendFile(string(decoded))
|
||||
// TODO: further path / file validations
|
||||
if strings.Contains(filepath.Dir(decodedStr), root) {
|
||||
// ctx.Response().Header.Set(
|
||||
// "Content-Disposition",
|
||||
// "inline; filename="+filepath.Base(decodedStr),
|
||||
// )
|
||||
ctx.SendStatus(fiber.StatusOK)
|
||||
return ctx.SendFile(decodedStr)
|
||||
}
|
||||
|
||||
ctx.Status(fiber.StatusOK)
|
||||
return ctx.SendStatus(fiber.StatusUnauthorized)
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
func Login(ctx *fiber.Ctx) error {
|
||||
req := new(LoginRequest)
|
||||
err := ctx.BodyParser(req)
|
||||
if err != nil {
|
||||
return ctx.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if config.Instance().GetConfig().RPCSecret != req.Secret {
|
||||
return ctx.SendStatus(fiber.StatusBadRequest)
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"expiresAt": time.Now().Add(time.Minute * 30),
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||
if err != nil {
|
||||
return ctx.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
|
||||
ctx.Cookie(&fiber.Cookie{
|
||||
Name: TOKEN_COOKIE_NAME,
|
||||
HTTPOnly: true,
|
||||
Secure: false,
|
||||
Expires: time.Now().Add(time.Hour * 24 * 30), // 30 days
|
||||
Value: tokenString,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
return ctx.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
func Logout(ctx *fiber.Ctx) error {
|
||||
ctx.Cookie(&fiber.Cookie{
|
||||
Name: TOKEN_COOKIE_NAME,
|
||||
HTTPOnly: true,
|
||||
Secure: false,
|
||||
Expires: time.Now(),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
return ctx.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
||||
)
|
||||
|
||||
@@ -35,21 +36,32 @@ func RunBlocking(port int, frontend fs.FS) {
|
||||
Root: http.FS(frontend),
|
||||
}))
|
||||
|
||||
// Client side routes
|
||||
app.Get("/settings", func(c *fiber.Ctx) error {
|
||||
return c.Redirect("/")
|
||||
})
|
||||
app.Get("/archive", func(c *fiber.Ctx) error {
|
||||
return c.Redirect("/")
|
||||
})
|
||||
app.Get("/login", func(c *fiber.Ctx) error {
|
||||
return c.Redirect("/")
|
||||
})
|
||||
|
||||
app.Post("/downloaded", rest.ListDownloaded)
|
||||
// Archive routes
|
||||
archive := app.Group("archive", middlewares.Authenticated)
|
||||
archive.Post("/downloaded", rest.ListDownloaded)
|
||||
archive.Post("/delete", rest.DeleteFile)
|
||||
archive.Get("/d/:id", rest.SendFile)
|
||||
|
||||
app.Post("/delete", rest.DeleteFile)
|
||||
app.Get("/play", rest.PlayFile)
|
||||
// Authentication routes
|
||||
app.Post("/auth/login", rest.Login)
|
||||
app.Get("/auth/logout", rest.Logout)
|
||||
|
||||
// RPC handlers
|
||||
// websocket
|
||||
app.Get("/ws-rpc", websocket.New(func(c *websocket.Conn) {
|
||||
rpc := app.Group("/rpc", middlewares.Authenticated)
|
||||
|
||||
rpc.Get("/ws", websocket.New(func(c *websocket.Conn) {
|
||||
c.WriteMessage(websocket.TextMessage, []byte(`{
|
||||
"status": "connected"
|
||||
}`))
|
||||
@@ -69,7 +81,7 @@ func RunBlocking(port int, frontend fs.FS) {
|
||||
}
|
||||
}))
|
||||
// http-post
|
||||
app.Post("/http-rpc", func(c *fiber.Ctx) error {
|
||||
rpc.Post("/http", func(c *fiber.Ctx) error {
|
||||
reader := c.Context().RequestBodyStream()
|
||||
writer := c.Response().BodyWriter()
|
||||
|
||||
@@ -81,8 +93,8 @@ func RunBlocking(port int, frontend fs.FS) {
|
||||
|
||||
app.Server().StreamRequestBody = true
|
||||
|
||||
go periodicallyPersist()
|
||||
go gracefulShutdown(app)
|
||||
go autoPersist(time.Minute * 5)
|
||||
|
||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
||||
}
|
||||
@@ -106,9 +118,9 @@ func gracefulShutdown(app *fiber.App) {
|
||||
}()
|
||||
}
|
||||
|
||||
func periodicallyPersist() {
|
||||
func autoPersist(d time.Duration) {
|
||||
for {
|
||||
db.Persist()
|
||||
time.Sleep(time.Minute * 5)
|
||||
time.Sleep(d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package server
|
||||
|
||||
import "time"
|
||||
|
||||
// Progress for the Running call
|
||||
type DownloadProgress struct {
|
||||
Percentage string `json:"percentage"`
|
||||
@@ -9,14 +11,15 @@ type DownloadProgress struct {
|
||||
|
||||
// Used to deser the yt-dlp -J output
|
||||
type DownloadInfo struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Resolution string `json:"resolution"`
|
||||
Size int32 `json:"filesize_approx"`
|
||||
VCodec string `json:"vcodec"`
|
||||
ACodec string `json:"acodec"`
|
||||
Extension string `json:"ext"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Resolution string `json:"resolution"`
|
||||
Size int32 `json:"filesize_approx"`
|
||||
VCodec string `json:"vcodec"`
|
||||
ACodec string `json:"acodec"`
|
||||
Extension string `json:"ext"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Used to deser the formats in the -J output
|
||||
|
||||
29
server/utils/file.go
Normal file
29
server/utils/file.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
videoRe = regexp.MustCompile(`(?i)/\.mov|\.mp4|\.webm|\.mvk|/gmi`)
|
||||
)
|
||||
|
||||
func IsVideo(d fs.DirEntry) bool {
|
||||
return videoRe.MatchString(d.Name())
|
||||
}
|
||||
|
||||
func IsValidEntry(d fs.DirEntry) bool {
|
||||
return !strings.HasPrefix(d.Name(), ".") &&
|
||||
!strings.HasSuffix(d.Name(), ".part") &&
|
||||
!strings.HasSuffix(d.Name(), ".ytdl")
|
||||
}
|
||||
|
||||
func ShaSumString(path string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(path))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
Reference in New Issue
Block a user