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
|
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
||||||
|
|
||||||
|
ENV JWT_SECRET=secret
|
||||||
|
|
||||||
EXPOSE 3033
|
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)**.
|
**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
|
```sh
|
||||||
docker pull marcobaobao/yt-dlp-webui:latest
|
docker pull marcobaobao/yt-dlp-webui
|
||||||
```
|
```
|
||||||
```sh
|
```sh
|
||||||
|
# latest stable
|
||||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
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
|
## Changelog
|
||||||
```
|
```
|
||||||
05/03/22: Korean translation by kimpig
|
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
|
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
|
||||||
```sh
|
```sh
|
||||||
# recomended for ARM and x86 devices
|
# recomended for ARM and x86 devices
|
||||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
docker pull marcobaobao/yt-dlp-webui
|
||||||
# or
|
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with docker but building the container manually.
|
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
|
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
|
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -132,6 +145,10 @@ The config file **will overwrite what have been passed as cli argument**.
|
|||||||
port: 8989
|
port: 8989
|
||||||
downloadPath: /home/ren/archive
|
downloadPath: /home/ren/archive
|
||||||
downloaderPath: /usr/local/bin/yt-dlp
|
downloaderPath: /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
|
# Optional settings
|
||||||
|
require_auth: true
|
||||||
|
rpc_secret: my_random_secret
|
||||||
```
|
```
|
||||||
|
|
||||||
### Systemd integration
|
### Systemd integration
|
||||||
@@ -187,16 +204,10 @@ For more information open an issue on GitHub and I will provide more info ASAP.
|
|||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
- **Will it availabe for Raspberry Pi/ generic ARM devices?**
|
- **Will it availabe for Raspberry Pi/ generic ARM devices?**
|
||||||
- Yes, it's currently available through ghcr.io
|
- Yes, it's cross platform :)
|
||||||
```
|
|
||||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
|
|
||||||
```
|
|
||||||
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
|
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
|
||||||
- **Why the docker image is so heavy?**
|
- **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.
|
- 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.
|
||||||
|
- **Update**: Since Golang migration and Multi-Stage builds the Docker image is now 75MB circa. A reduction of over 400% in size :D.
|
||||||
- **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....
|
|
||||||
|
|
||||||
## What yt-dlp-webui is not
|
## 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.
|
`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/icons-material": "^5.11.16",
|
||||||
"@mui/material": "^5.13.2",
|
"@mui/material": "^5.13.2",
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
|
"fp-ts": "^2.16.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-redux": "^8.0.5",
|
"react-redux": "^8.0.5",
|
||||||
|
|||||||
@@ -1,208 +1,12 @@
|
|||||||
import { ThemeProvider } from '@emotion/react'
|
import { Provider } from 'react-redux'
|
||||||
|
import { RouterProvider } from 'react-router-dom'
|
||||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
import { router } from './router'
|
||||||
import Dashboard from '@mui/icons-material/Dashboard'
|
import { store } from './stores/store'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<AppContent />
|
<RouterProvider router={router} />
|
||||||
</Provider>
|
</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 参数
|
customArgsInput: 自定义 yt-dlp 参数
|
||||||
rpcConnErr: Error while conencting to RPC server
|
rpcConnErr: Error while conencting to RPC server
|
||||||
spanish:
|
spanish:
|
||||||
urlInput: YouTube or other supported service video url
|
urlInput: URL de YouTube u otro servicio compatible
|
||||||
statusTitle: Status
|
statusTitle: Estado
|
||||||
startButton: Start
|
startButton: Iniciar
|
||||||
statusReady: Ready
|
statusReady: Listo
|
||||||
abortAllButton: Abort All
|
abortAllButton: Cancelar Todo
|
||||||
updateBinButton: Update yt-dlp binary
|
updateBinButton: Actualizar el binario yt-dlp
|
||||||
darkThemeButton: Dark theme
|
darkThemeButton: Tema oscuro
|
||||||
lightThemeButton: Light theme
|
lightThemeButton: Tema claro
|
||||||
settingsAnchor: Settings
|
settingsAnchor: Ajustes
|
||||||
serverAddressTitle: Server address
|
serverAddressTitle: Dirección del servidor
|
||||||
serverPortTitle: Port
|
serverPortTitle: Puerto
|
||||||
extractAudioCheckbox: Extract audio
|
extractAudioCheckbox: Extraer audio
|
||||||
noMTimeCheckbox: Don't set file modification time
|
noMTimeCheckbox: No guardar el tiempo de modificación del archivo
|
||||||
bgReminder: Once you close this page the download will continue in the background.
|
bgReminder: Si cierras esta página, la descarga continuará en segundo plano.
|
||||||
toastConnected: 'Connected to '
|
toastConnected: 'Conectado a'
|
||||||
toastUpdated: Updated yt-dlp binary!
|
toastUpdated: ¡El binario yt-dlp está actualizado!
|
||||||
formatSelectionEnabler: Enable video/audio formats selection
|
formatSelectionEnabler: Habilitar la selección de formatos de video/audio
|
||||||
themeSelect: 'Theme'
|
themeSelect: 'Tema'
|
||||||
languageSelect: 'Language'
|
languageSelect: 'Idiomas'
|
||||||
overridesAnchor: Overrides
|
overridesAnchor: Anulaciones
|
||||||
pathOverrideOption: Enable output path overriding
|
pathOverrideOption: Sobreescribir en la ruta de salida
|
||||||
filenameOverrideOption: Enable output file name overriding
|
filenameOverrideOption: Sobreescribir el nombre del fichero
|
||||||
customFilename: Custom filemame (leave blank to use default)
|
customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
|
||||||
customPath: Custom path
|
customPath: Ruta personalizada
|
||||||
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
|
customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
|
||||||
customArgsInput: Custom yt-dlp arguments
|
customArgsInput: Argumentos yt-dlp personalizados
|
||||||
rpcConnErr: Error while conencting to RPC server
|
rpcConnErr: Error al conectarse al servidor RPC
|
||||||
russian:
|
russian:
|
||||||
urlInput: YouTube or other supported service video url
|
urlInput: YouTube or other supported service video url
|
||||||
statusTitle: Status
|
statusTitle: Status
|
||||||
@@ -197,4 +197,32 @@ languages:
|
|||||||
customPath: 保存先
|
customPath: 保存先
|
||||||
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
|
||||||
customArgsInput: yt-dlpのオプション
|
customArgsInput: yt-dlpのオプション
|
||||||
rpcConnErr: Error while conencting to RPC server
|
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 (
|
return (
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TableContainer component={Paper} sx={{ minHeight: '65vh' }} elevation={2}>
|
<TableContainer component={Paper} sx={{ minHeight: '80vh' }} elevation={2}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<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"
|
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 type ThemeUnion = "light" | "dark"
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
|
||||||
const root = createRoot(document.getElementById('root')!)
|
const root = createRoot(document.getElementById('root')!)
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
export class CliArguments {
|
export class CliArguments {
|
||||||
private _extractAudio: boolean;
|
private _extractAudio: boolean
|
||||||
private _noMTime: boolean;
|
private _noMTime: boolean
|
||||||
private _proxy: string;
|
private _proxy: string
|
||||||
|
|
||||||
constructor(extractAudio = false, noMTime = false) {
|
constructor(extractAudio = false, noMTime = true) {
|
||||||
this._extractAudio = extractAudio;
|
this._extractAudio = extractAudio
|
||||||
this._noMTime = noMTime;
|
this._noMTime = noMTime
|
||||||
this._proxy = ""
|
this._proxy = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
public get extractAudio(): boolean {
|
public get extractAudio(): boolean {
|
||||||
return this._extractAudio;
|
return this._extractAudio
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleExtractAudio() {
|
public toggleExtractAudio() {
|
||||||
this._extractAudio = !this._extractAudio;
|
this._extractAudio = !this._extractAudio
|
||||||
return this;
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
public disableExtractAudio() {
|
public disableExtractAudio() {
|
||||||
this._extractAudio = false;
|
this._extractAudio = false
|
||||||
return this;
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
public get noMTime(): boolean {
|
public get noMTime(): boolean {
|
||||||
return this._noMTime;
|
return this._noMTime
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleNoMTime() {
|
public toggleNoMTime() {
|
||||||
this._noMTime = !this._noMTime;
|
this._noMTime = !this._noMTime
|
||||||
return this;
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
public toString(): string {
|
public toString(): string {
|
||||||
let args = '';
|
let args = ''
|
||||||
|
|
||||||
if (this._extractAudio) {
|
if (this._extractAudio) {
|
||||||
args += '-x '
|
args += '-x '
|
||||||
@@ -43,19 +43,19 @@ export class CliArguments {
|
|||||||
args += '--no-mtime '
|
args += '--no-mtime '
|
||||||
}
|
}
|
||||||
|
|
||||||
return args.trim();
|
return args.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
public fromString(str: string): CliArguments {
|
public fromString(str: string): CliArguments {
|
||||||
if (str) {
|
if (str) {
|
||||||
if (str.includes('-x')) {
|
if (str.includes('-x')) {
|
||||||
this._extractAudio = true;
|
this._extractAudio = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str.includes('--no-mtime')) {
|
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
|
// @ts-nocheck
|
||||||
import i18n from "../../assets/i18n.yaml"
|
import i18n from "../assets/i18n.yaml"
|
||||||
|
|
||||||
export default class I18nBuilder {
|
export default class I18nBuilder {
|
||||||
private language: string
|
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 { webSocket } from 'rxjs/webSocket'
|
||||||
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../../utils'
|
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../utils'
|
||||||
|
|
||||||
export const socket$ = webSocket<any>(getWebSocketEndpoint())
|
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
|
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 = {
|
export type RPCResult = {
|
||||||
id: string
|
id: string
|
||||||
progress: {
|
progress: DownloadProgress
|
||||||
speed: number
|
info: DownloadInfo
|
||||||
eta: number
|
|
||||||
percentage: string
|
|
||||||
}
|
|
||||||
info: {
|
|
||||||
url: string
|
|
||||||
filesize_approx?: number
|
|
||||||
resolution?: string
|
|
||||||
thumbnail: string
|
|
||||||
title: string
|
|
||||||
vcodec?: string
|
|
||||||
acodec?: string
|
|
||||||
ext?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RPCParams = {
|
export type RPCParams = {
|
||||||
@@ -65,11 +70,14 @@ export type DLFormat = {
|
|||||||
export type DirectoryEntry = {
|
export type DirectoryEntry = {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
|
size: number
|
||||||
shaSum: string
|
shaSum: string
|
||||||
|
modTime: string
|
||||||
|
isVideo: boolean
|
||||||
isDirectory: 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
|
* @returns ip validity test
|
||||||
*/
|
*/
|
||||||
export function validateIP(ipAddr: string): boolean {
|
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
|
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)
|
return ipRegex.test(ipAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,8 +18,8 @@ export function validateIP(ipAddr: string): boolean {
|
|||||||
* @returns domain validity test
|
* @returns domain validity test
|
||||||
*/
|
*/
|
||||||
export function validateDomain(domainName: string): boolean {
|
export function validateDomain(domainName: string): boolean {
|
||||||
let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/
|
let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/
|
||||||
return domainRegex.test(domainName) || domainName === 'localhost'
|
return domainRegex.test(domainName) || domainName === 'localhost'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,15 +34,15 @@ export function validateDomain(domainName: string): boolean {
|
|||||||
* @returns url validity test
|
* @returns url validity test
|
||||||
*/
|
*/
|
||||||
export function isValidURL(url: string): boolean {
|
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()!@:%_\+.~#?&\/\/=]*)/
|
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)
|
return urlRegex.test(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ellipsis(str: string, lim: number): string {
|
export function ellipsis(str: string, lim: number): string {
|
||||||
if (str) {
|
if (str) {
|
||||||
return str.length > lim ? `${str.substring(0, lim)}...` : str
|
return str.length > lim ? `${str.substring(0, lim)}...` : str
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,44 +51,46 @@ export function ellipsis(str: string, lim: number): string {
|
|||||||
* @returns download speed in KiB/s
|
* @returns download speed in KiB/s
|
||||||
*/
|
*/
|
||||||
export function detectSpeed(str: string): number {
|
export function detectSpeed(str: string): number {
|
||||||
let effective = str.match(/[\d,]+(\.\d+)?/)![0]
|
let effective = str.match(/[\d,]+(\.\d+)?/)![0]
|
||||||
const unit = str.replace(effective, '')
|
const unit = str.replace(effective, '')
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 'MiB/s':
|
case 'MiB/s':
|
||||||
return Number(effective) * 1000
|
return Number(effective) * 1000
|
||||||
case 'KiB/s':
|
case 'KiB/s':
|
||||||
return Number(effective)
|
return Number(effective)
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toFormatArgs(codes: string[]): string {
|
export function toFormatArgs(codes: string[]): string {
|
||||||
if (codes.length > 1) {
|
if (codes.length > 1) {
|
||||||
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
||||||
}
|
}
|
||||||
if (codes.length === 1) {
|
if (codes.length === 1) {
|
||||||
return ` -f ${codes[0]}`;
|
return ` -f ${codes[0]}`;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWebSocketEndpoint() {
|
export function getWebSocketEndpoint() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
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`
|
return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/ws`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHttpRPCEndpoint() {
|
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() {
|
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) {
|
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 roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
|
||||||
export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
|
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'
|
} from '@mui/material'
|
||||||
|
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
|
||||||
import FolderIcon from '@mui/icons-material/Folder'
|
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 { Buffer } from 'buffer'
|
||||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
||||||
import { useObservable } from './hooks/observable'
|
import { useObservable } from '../hooks/observable'
|
||||||
import { RootState } from './stores/store'
|
import { RootState } from '../stores/store'
|
||||||
import { DeleteRequest, DirectoryEntry } from './types'
|
import { DeleteRequest, DirectoryEntry } from '../types'
|
||||||
|
import { roundMiB } from '../utils'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
|
||||||
export default function Downloaded() {
|
export default function Downloaded() {
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const settings = useSelector((state: RootState) => state.settings)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
@@ -45,37 +51,52 @@ export default function Downloaded() {
|
|||||||
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const fetcher = () => fetch(`${serverAddr}/downloaded`, {
|
const fetcher = () => ffetch<DirectoryEntry[]>(
|
||||||
method: 'POST',
|
`${serverAddr}/archive/downloaded`,
|
||||||
headers: {
|
(d) => files$.next(d),
|
||||||
'Content-Type': 'application/json',
|
() => navigate('/login'),
|
||||||
},
|
{
|
||||||
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`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
files$.next([{
|
files$.next(sub
|
||||||
isDirectory: true,
|
? [{
|
||||||
name: '..',
|
name: '..',
|
||||||
path: '',
|
isDirectory: true,
|
||||||
}, ...data])
|
path: upperLevel,
|
||||||
|
}, ...data]
|
||||||
|
: data
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +120,7 @@ export default function Downloaded() {
|
|||||||
const deleteSelected = () => {
|
const deleteSelected = () => {
|
||||||
Promise.all(selectable
|
Promise.all(selectable
|
||||||
.filter(entry => entry.selected)
|
.filter(entry => entry.selected)
|
||||||
.map(entry => fetch(`${serverAddr}/delete`, {
|
.map(entry => fetch(`${serverAddr}/archive/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -118,7 +139,7 @@ export default function Downloaded() {
|
|||||||
|
|
||||||
|
|
||||||
const onFileClick = (path: string) => startTransition(() => {
|
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(() => {
|
const onFolderClick = (path: string) => startTransition(() => {
|
||||||
@@ -138,7 +159,7 @@ export default function Downloaded() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}>
|
}}>
|
||||||
<Typography pb={0} variant="h5" color="primary">
|
<Typography py={1} variant="h5" color="primary">
|
||||||
{'Archive'}
|
{'Archive'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||||
@@ -147,11 +168,20 @@ export default function Downloaded() {
|
|||||||
<ListItem
|
<ListItem
|
||||||
key={idx}
|
key={idx}
|
||||||
secondaryAction={
|
secondaryAction={
|
||||||
!file.isDirectory && <Checkbox
|
<div>
|
||||||
edge="end"
|
{!file.isDirectory && <Typography
|
||||||
checked={file.selected}
|
variant="caption"
|
||||||
onChange={() => addSelected(file.name)}
|
component="span"
|
||||||
/>
|
>
|
||||||
|
{roundMiB(file.size)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
{!file.isDirectory && <Checkbox
|
||||||
|
edge="end"
|
||||||
|
checked={file.selected}
|
||||||
|
onChange={() => addSelected(file.name)}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
disablePadding
|
disablePadding
|
||||||
>
|
>
|
||||||
@@ -163,10 +193,15 @@ export default function Downloaded() {
|
|||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{file.isDirectory
|
{file.isDirectory
|
||||||
? <FolderIcon />
|
? <FolderIcon />
|
||||||
: <VideoFileIcon />
|
: file.isVideo
|
||||||
|
? <VideoFileIcon />
|
||||||
|
: <InsertDriveFileIcon />
|
||||||
}
|
}
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={file.name} />
|
<ListItemText
|
||||||
|
primary={file.name}
|
||||||
|
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
||||||
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</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,
|
map,
|
||||||
takeWhile
|
takeWhile
|
||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
import { CliArguments } from './features/core/argsParser'
|
import { CliArguments } from '../lib/argsParser'
|
||||||
import I18nBuilder from './features/core/intl'
|
import I18nBuilder from '../lib/intl'
|
||||||
import { RPCClient } from './features/core/rpcClient'
|
import { RPCClient } from '../lib/rpcClient'
|
||||||
import {
|
import {
|
||||||
LanguageUnion,
|
LanguageUnion,
|
||||||
ThemeUnion,
|
ThemeUnion,
|
||||||
@@ -41,10 +41,10 @@ import {
|
|||||||
setServerAddr,
|
setServerAddr,
|
||||||
setServerPort,
|
setServerPort,
|
||||||
setTheme
|
setTheme
|
||||||
} from './features/settings/settingsSlice'
|
} from '../features/settings/settingsSlice'
|
||||||
import { updated } from './features/status/statusSlice'
|
import { updated } from '../features/status/statusSlice'
|
||||||
import { RootState } from './stores/store'
|
import { RootState } from '../stores/store'
|
||||||
import { validateDomain, validateIP } from './utils'
|
import { validateDomain, validateIP } from '../utils'
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@@ -173,6 +173,7 @@ export default function Settings() {
|
|||||||
<MenuItem value="russian">Russian</MenuItem>
|
<MenuItem value="russian">Russian</MenuItem>
|
||||||
<MenuItem value="korean">Korean</MenuItem>
|
<MenuItem value="korean">Korean</MenuItem>
|
||||||
<MenuItem value="japanese">Japanese</MenuItem>
|
<MenuItem value="japanese">Japanese</MenuItem>
|
||||||
|
<MenuItem value="catalan">Catalan</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
17
go.mod
17
go.mod
@@ -1,23 +1,24 @@
|
|||||||
module github.com/marcopeocchi/yt-dlp-web-ui
|
module github.com/marcopeocchi/yt-dlp-web-ui
|
||||||
|
|
||||||
go 1.19
|
go 1.20
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/goccy/go-json v0.10.2
|
github.com/goccy/go-json v0.10.2
|
||||||
github.com/gofiber/fiber/v2 v2.43.0
|
github.com/gofiber/fiber/v2 v2.47.0
|
||||||
github.com/gofiber/websocket/v2 v2.1.5
|
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/google/uuid v1.3.0
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
|
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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||||
github.com/fasthttp/websocket v1.5.2 // indirect
|
github.com/fasthttp/websocket v1.5.3 // indirect
|
||||||
github.com/klauspost/compress v1.16.4 // indirect
|
github.com/klauspost/compress v1.16.6 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // 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/mattn/go-runewidth v0.0.14 // indirect
|
||||||
github.com/philhofer/fwd v1.1.2 // indirect
|
github.com/philhofer/fwd v1.1.2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.4 // 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/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
|
||||||
github.com/tinylib/msgp v1.1.8 // indirect
|
github.com/tinylib/msgp v1.1.8 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // 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
|
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 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
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.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
|
||||||
github.com/fasthttp/websocket v1.5.2/go.mod h1:S0KC1VBlx1SaXGXq7yi1wKz4jMub58qEnHQG9oHuqBw=
|
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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs=
|
||||||
github.com/gofiber/fiber/v2 v2.43.0/go.mod h1:mpS1ZNE5jU+u+BA4FbM+KKnUzJ4wzTK+FT2tG3tU+6I=
|
github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU=
|
||||||
github.com/gofiber/websocket/v2 v2.1.5 h1:2weAMr0Shb2ubhZ3+P4bkeWL+uCZ/NlgjSa1siEcvFM=
|
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
|
||||||
github.com/gofiber/websocket/v2 v2.1.5/go.mod h1:BZZEk+XsjjF0V6/sAw00iGcB69dFb6Hb85ER9gr/xaU=
|
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 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
|
||||||
github.com/klauspost/compress v1.16.4/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
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 h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
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-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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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=
|
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/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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
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.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc=
|
||||||
github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
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 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
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.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.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||||
|
|||||||
28
main.go
28
main.go
@@ -12,19 +12,26 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
port int
|
port int
|
||||||
|
configFile string
|
||||||
downloadPath string
|
downloadPath string
|
||||||
downloaderPath string
|
downloaderPath string
|
||||||
configFile string
|
|
||||||
|
|
||||||
|
requireAuth bool
|
||||||
|
rpcSecret string
|
||||||
//go:embed frontend/dist
|
//go:embed frontend/dist
|
||||||
frontend embed.FS
|
frontend embed.FS
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
|
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(&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()
|
flag.Parse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,15 +42,18 @@ func main() {
|
|||||||
log.Fatalln(err)
|
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 != "" {
|
if configFile != "" {
|
||||||
cfg.LoadFromFile(configFile)
|
c.LoadFromFile(configFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.SetPort(port)
|
|
||||||
cfg.DownloadPath(downloadPath)
|
|
||||||
cfg.DownloaderPath(downloaderPath)
|
|
||||||
|
|
||||||
server.RunBlocking(port, frontend)
|
server.RunBlocking(port, frontend)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lock = &sync.Mutex{}
|
var lock sync.Mutex
|
||||||
|
|
||||||
type serverConfig struct {
|
type serverConfig struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
DownloadPath string `yaml:"downloadPath"`
|
DownloadPath string `yaml:"downloadPath"`
|
||||||
DownloaderPath string `yaml:"downloaderPath"`
|
DownloaderPath string `yaml:"downloaderPath"`
|
||||||
|
RequireAuth bool `yaml:"require_auth"`
|
||||||
|
RPCSecret string `yaml:"rpc_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -46,6 +48,13 @@ func (c *config) DownloaderPath(path string) {
|
|||||||
c.cfg.DownloaderPath = path
|
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
|
var instance *config
|
||||||
|
|
||||||
func 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 {
|
if err != nil {
|
||||||
log.Println("Cannot retrieve info for", p.url)
|
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)
|
json.Unmarshal(stdout, &info)
|
||||||
p.mem.UpdateInfo(p.id, info)
|
p.mem.UpdateInfo(p.id, info)
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -1,36 +1,33 @@
|
|||||||
package rest
|
package rest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"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/config"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TOKEN_COOKIE_NAME = "jwt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DirectoryEntry struct {
|
type DirectoryEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
SHASum string `json:"shaSum"`
|
Size int64 `json:"size"`
|
||||||
IsDirectory bool `json:"isDirectory"`
|
SHASum string `json:"shaSum"`
|
||||||
}
|
ModTime time.Time `json:"modTime"`
|
||||||
|
IsVideo bool `json:"isVideo"`
|
||||||
func isValidEntry(d fs.DirEntry) bool {
|
IsDirectory bool `json:"isDirectory"`
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func walkDir(root string) (*[]DirectoryEntry, error) {
|
func walkDir(root string) (*[]DirectoryEntry, error) {
|
||||||
@@ -42,17 +39,25 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range dirs {
|
for _, d := range dirs {
|
||||||
if !isValidEntry(d) {
|
if !utils.IsValidEntry(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(root, d.Name())
|
path := filepath.Join(root, d.Name())
|
||||||
|
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
files = append(files, DirectoryEntry{
|
files = append(files, DirectoryEntry{
|
||||||
Path: path,
|
Path: path,
|
||||||
Name: d.Name(),
|
Name: d.Name(),
|
||||||
SHASum: shaSumString(path),
|
Size: info.Size(),
|
||||||
|
SHASum: utils.ShaSumString(path),
|
||||||
|
IsVideo: utils.IsVideo(d),
|
||||||
IsDirectory: d.IsDir(),
|
IsDirectory: d.IsDir(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +65,8 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListRequest struct {
|
type ListRequest struct {
|
||||||
SubDir string `json:"subdir"`
|
SubDir string `json:"subdir"`
|
||||||
|
OrderBy string `json:"orderBy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListDownloaded(ctx *fiber.Ctx) error {
|
func ListDownloaded(ctx *fiber.Ctx) error {
|
||||||
@@ -77,6 +83,12 @@ func ListDownloaded(ctx *fiber.Ctx) error {
|
|||||||
return err
|
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)
|
ctx.Status(http.StatusOK)
|
||||||
return ctx.JSON(files)
|
return ctx.JSON(files)
|
||||||
}
|
}
|
||||||
@@ -91,7 +103,7 @@ func DeleteFile(ctx *fiber.Ctx) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sum := shaSumString(req.Path)
|
sum := utils.ShaSumString(req.Path)
|
||||||
if sum != req.SHASum {
|
if sum != req.SHASum {
|
||||||
return errors.New("shasum mismatch")
|
return errors.New("shasum mismatch")
|
||||||
}
|
}
|
||||||
@@ -105,12 +117,8 @@ func DeleteFile(ctx *fiber.Ctx) error {
|
|||||||
return ctx.JSON("ok")
|
return ctx.JSON("ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayRequest struct {
|
func SendFile(ctx *fiber.Ctx) error {
|
||||||
Path string
|
path := ctx.Params("id")
|
||||||
}
|
|
||||||
|
|
||||||
func PlayFile(ctx *fiber.Ctx) error {
|
|
||||||
path := ctx.Query("path")
|
|
||||||
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return errors.New("inexistent path")
|
return errors.New("inexistent path")
|
||||||
@@ -120,16 +128,68 @@ func PlayFile(ctx *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
decodedStr := string(decoded)
|
||||||
|
|
||||||
root := config.Instance().GetConfig().DownloadPath
|
root := config.Instance().GetConfig().DownloadPath
|
||||||
|
|
||||||
//TODO: further path / file validations
|
// TODO: further path / file validations
|
||||||
|
if strings.Contains(filepath.Dir(decodedStr), root) {
|
||||||
if strings.Contains(filepath.Dir(string(decoded)), root) {
|
// ctx.Response().Header.Set(
|
||||||
ctx.SendStatus(fiber.StatusPartialContent)
|
// "Content-Disposition",
|
||||||
return ctx.SendFile(string(decoded))
|
// "inline; filename="+filepath.Base(decodedStr),
|
||||||
|
// )
|
||||||
|
ctx.SendStatus(fiber.StatusOK)
|
||||||
|
return ctx.SendFile(decodedStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Status(fiber.StatusOK)
|
|
||||||
return ctx.SendStatus(fiber.StatusUnauthorized)
|
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/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||||
"github.com/gofiber/websocket/v2"
|
"github.com/gofiber/websocket/v2"
|
||||||
|
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,21 +36,32 @@ func RunBlocking(port int, frontend fs.FS) {
|
|||||||
Root: http.FS(frontend),
|
Root: http.FS(frontend),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Client side routes
|
||||||
app.Get("/settings", func(c *fiber.Ctx) error {
|
app.Get("/settings", func(c *fiber.Ctx) error {
|
||||||
return c.Redirect("/")
|
return c.Redirect("/")
|
||||||
})
|
})
|
||||||
app.Get("/archive", func(c *fiber.Ctx) error {
|
app.Get("/archive", func(c *fiber.Ctx) error {
|
||||||
return c.Redirect("/")
|
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)
|
// Authentication routes
|
||||||
app.Get("/play", rest.PlayFile)
|
app.Post("/auth/login", rest.Login)
|
||||||
|
app.Get("/auth/logout", rest.Logout)
|
||||||
|
|
||||||
// RPC handlers
|
// RPC handlers
|
||||||
// websocket
|
// 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(`{
|
c.WriteMessage(websocket.TextMessage, []byte(`{
|
||||||
"status": "connected"
|
"status": "connected"
|
||||||
}`))
|
}`))
|
||||||
@@ -69,7 +81,7 @@ func RunBlocking(port int, frontend fs.FS) {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
// http-post
|
// http-post
|
||||||
app.Post("/http-rpc", func(c *fiber.Ctx) error {
|
rpc.Post("/http", func(c *fiber.Ctx) error {
|
||||||
reader := c.Context().RequestBodyStream()
|
reader := c.Context().RequestBodyStream()
|
||||||
writer := c.Response().BodyWriter()
|
writer := c.Response().BodyWriter()
|
||||||
|
|
||||||
@@ -81,8 +93,8 @@ func RunBlocking(port int, frontend fs.FS) {
|
|||||||
|
|
||||||
app.Server().StreamRequestBody = true
|
app.Server().StreamRequestBody = true
|
||||||
|
|
||||||
go periodicallyPersist()
|
|
||||||
go gracefulShutdown(app)
|
go gracefulShutdown(app)
|
||||||
|
go autoPersist(time.Minute * 5)
|
||||||
|
|
||||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
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 {
|
for {
|
||||||
db.Persist()
|
db.Persist()
|
||||||
time.Sleep(time.Minute * 5)
|
time.Sleep(d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// Progress for the Running call
|
// Progress for the Running call
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
Percentage string `json:"percentage"`
|
Percentage string `json:"percentage"`
|
||||||
@@ -9,14 +11,15 @@ type DownloadProgress struct {
|
|||||||
|
|
||||||
// Used to deser the yt-dlp -J output
|
// Used to deser the yt-dlp -J output
|
||||||
type DownloadInfo struct {
|
type DownloadInfo struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Thumbnail string `json:"thumbnail"`
|
Thumbnail string `json:"thumbnail"`
|
||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
Size int32 `json:"filesize_approx"`
|
Size int32 `json:"filesize_approx"`
|
||||||
VCodec string `json:"vcodec"`
|
VCodec string `json:"vcodec"`
|
||||||
ACodec string `json:"acodec"`
|
ACodec string `json:"acodec"`
|
||||||
Extension string `json:"ext"`
|
Extension string `json:"ext"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used to deser the formats in the -J output
|
// 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