Compare commits
36 Commits
126-svelte
...
v3.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 00bacf5c41 | |||
| 01c327d308 | |||
| 83f6444df2 | |||
| e09c52dd05 | |||
| 3da81affb3 | |||
| a73b8d362e | |||
| 205f2e5cdf | |||
| 294ad29bf2 | |||
| d336b92e46 | |||
| 2f02293a52 | |||
| 566f0f2ac2 | |||
| 15ab37de11 | |||
|
|
f2fab66626 | ||
|
|
86db8176ff | ||
| 1b8d2e0da6 | |||
| c6e48f4baa | |||
| 82ccb68a56 | |||
| bf2e24009e | |||
|
|
e7639c2720 | ||
|
|
02832f9de4 | ||
| 29dfebe48b | |||
|
|
52862156b9 | ||
|
|
193ac9f043 | ||
|
|
6e4dff5f3a | ||
| 371704db57 | |||
| 43e5c94b58 | |||
| 48c9258088 | |||
|
|
87956a6aad | ||
| d4305bb2f8 | |||
| 3f836d0fa6 | |||
| b45107c94b | |||
|
|
9cf1a3bc7e | ||
| df3522fcb3 | |||
| e2c27c3857 | |||
|
|
51bcd82ea7 | ||
|
|
f763b9657f |
@@ -1,18 +0,0 @@
|
|||||||
dist
|
|
||||||
package-lock.json
|
|
||||||
pnpm-lock.yaml
|
|
||||||
.pnpm-debug.log
|
|
||||||
node_modules
|
|
||||||
.env
|
|
||||||
*.mp4
|
|
||||||
*.ytdl
|
|
||||||
*.part
|
|
||||||
*.db
|
|
||||||
downloads
|
|
||||||
.DS_Store
|
|
||||||
build/
|
|
||||||
yt-dlp-webui
|
|
||||||
session.dat
|
|
||||||
config.yml
|
|
||||||
cookies.txt
|
|
||||||
examples/
|
|
||||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
# v3.1.2
|
# v3.1.2
|
||||||
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19
|
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19
|
||||||
with:
|
with:
|
||||||
cosign-release: 'v1.13.1'
|
cosign-release: 'v1.13.6'
|
||||||
|
|
||||||
- name: Set up QEMU for ARM emulation
|
- name: Set up QEMU for ARM emulation
|
||||||
# v2.2.0
|
# v2.2.0
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,4 +14,5 @@ session.dat
|
|||||||
config.yml
|
config.yml
|
||||||
cookies.txt
|
cookies.txt
|
||||||
__debug*
|
__debug*
|
||||||
app/
|
ui/
|
||||||
|
.idea
|
||||||
38
Dockerfile
38
Dockerfile
@@ -1,26 +1,40 @@
|
|||||||
FROM golang:alpine AS build
|
# Node (pnpm) ------------------------------------------------------------------
|
||||||
|
FROM node:20-slim AS ui
|
||||||
RUN apk update && \
|
ENV PNPM_HOME="/pnpm"
|
||||||
apk add nodejs npm
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
COPY . /usr/src/yt-dlp-webui
|
COPY . /usr/src/yt-dlp-webui
|
||||||
|
|
||||||
WORKDIR /usr/src/yt-dlp-webui/frontend
|
WORKDIR /usr/src/yt-dlp-webui/frontend
|
||||||
|
|
||||||
RUN npm install
|
RUN rm -rf node_modules
|
||||||
RUN npm run build
|
|
||||||
|
RUN pnpm install
|
||||||
|
RUN pnpm run build
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Go --------------------------------------------------------------------------
|
||||||
|
FROM golang AS build
|
||||||
|
|
||||||
WORKDIR /usr/src/yt-dlp-webui
|
WORKDIR /usr/src/yt-dlp-webui
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
|
|
||||||
|
|
||||||
FROM alpine:edge
|
COPY . .
|
||||||
|
COPY --from=ui /usr/src/yt-dlp-webui/frontend /usr/src/yt-dlp-webui/frontend
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# dependencies ----------------------------------------------------------------
|
||||||
|
FROM cgr.dev/chainguard/wolfi-base
|
||||||
|
|
||||||
|
RUN apk update && \
|
||||||
|
apk add ffmpeg ca-certificates python3 py3-pip
|
||||||
|
|
||||||
VOLUME /downloads /config
|
VOLUME /downloads /config
|
||||||
|
|
||||||
WORKDIR /app
|
RUN python3 -m pip install yt-dlp
|
||||||
|
|
||||||
RUN apk update && \
|
WORKDIR /app
|
||||||
apk add psmisc ffmpeg yt-dlp --no-cache
|
|
||||||
|
|
||||||
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
||||||
|
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -7,9 +7,10 @@ all:
|
|||||||
|
|
||||||
multiarch:
|
multiarch:
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -o build/yt-dlp-webui_linux-arm main.go
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv6 main.go
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv7 main.go
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build
|
rm -rf build
|
||||||
27
README.md
27
README.md
@@ -23,7 +23,9 @@ docker pull marcobaobao/yt-dlp-webui
|
|||||||
# latest dev
|
# latest dev
|
||||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
||||||
```
|
```
|
||||||

|
|
||||||
|
[app.webm](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964)
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -180,16 +182,31 @@ The config file **will overwrite what have been passed as cli argument**.
|
|||||||
# Simple configuration file for yt-dlp webui
|
# Simple configuration file for yt-dlp webui
|
||||||
|
|
||||||
---
|
---
|
||||||
port: 8989
|
# Host where server will listen at (default: "0.0.0.0")
|
||||||
downloadPath: /home/ren/archive
|
#host: 0.0.0.0
|
||||||
downloaderPath: /usr/local/bin/yt-dlp
|
|
||||||
|
|
||||||
# Optional settings
|
# Port where server will listen at (default: 3033)
|
||||||
|
port: 8989
|
||||||
|
|
||||||
|
# Directory where downloaded files will be stored (default: ".")
|
||||||
|
downloadPath: /home/ren/archive
|
||||||
|
|
||||||
|
# [optional] Enable RPC authentication (requires username and password)
|
||||||
require_auth: true
|
require_auth: true
|
||||||
username: my_username
|
username: my_username
|
||||||
password: my_random_secret
|
password: my_random_secret
|
||||||
|
|
||||||
|
# [optional] The download queue size (default: 8)
|
||||||
queue_size: 4
|
queue_size: 4
|
||||||
|
|
||||||
|
# [optional] Full path to the yt-dlp (default: "yt-dlp")
|
||||||
|
downloaderPath: /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
|
# [optional] Directory where the log file will be stored (default: ".")
|
||||||
|
#log_path: .
|
||||||
|
|
||||||
|
# [optional] Directory where the session database file will be stored (default: ".")
|
||||||
|
#session_file_path: .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Systemd integration
|
### Systemd integration
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="./src/assets/favicon.ico">
|
||||||
<title>yt-dlp Web UI</title>
|
<title>yt-dlp Web UI</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
3249
frontend/package-lock.json
generated
3249
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@
|
|||||||
"@mui/icons-material": "^5.15.4",
|
"@mui/icons-material": "^5.15.4",
|
||||||
"@mui/material": "^5.15.4",
|
"@mui/material": "^5.15.4",
|
||||||
"fp-ts": "^2.16.2",
|
"fp-ts": "^2.16.2",
|
||||||
"million": "^2.6.4",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.21.2",
|
"react-router-dom": "^6.21.2",
|
||||||
@@ -25,14 +24,15 @@
|
|||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"@types/node": "^20.11.4",
|
"@types/node": "^20.11.4",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/react-helmet": "^6.1.11",
|
"@types/react-helmet": "^6.1.11",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.4.3",
|
||||||
"vite": "^5.0.11"
|
"vite": "^5.2.6",
|
||||||
|
"million": "^3.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
560
frontend/pnpm-lock.yaml
generated
560
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
|||||||
import { ThemeProvider } from '@emotion/react'
|
import { ThemeProvider } from '@emotion/react'
|
||||||
|
import ArchiveIcon from '@mui/icons-material/Archive'
|
||||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||||
import Dashboard from '@mui/icons-material/Dashboard'
|
import Dashboard from '@mui/icons-material/Dashboard'
|
||||||
import DownloadIcon from '@mui/icons-material/Download'
|
|
||||||
import Menu from '@mui/icons-material/Menu'
|
import Menu from '@mui/icons-material/Menu'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||||
import { Box, createTheme } from '@mui/material'
|
import { Box, createTheme } from '@mui/material'
|
||||||
import CssBaseline from '@mui/material/CssBaseline'
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
@@ -16,26 +16,23 @@ import ListItemText from '@mui/material/ListItemText'
|
|||||||
import Toolbar from '@mui/material/Toolbar'
|
import Toolbar from '@mui/material/Toolbar'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { grey } from '@mui/material/colors'
|
import { grey } from '@mui/material/colors'
|
||||||
import { Suspense, useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link, Outlet } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
import { useRecoilValue } from 'recoil'
|
import { useRecoilValue } from 'recoil'
|
||||||
import { settingsState } from './atoms/settings'
|
import { settingsState } from './atoms/settings'
|
||||||
import { connectedState } from './atoms/status'
|
|
||||||
import AppBar from './components/AppBar'
|
import AppBar from './components/AppBar'
|
||||||
import Drawer from './components/Drawer'
|
import Drawer from './components/Drawer'
|
||||||
import FreeSpaceIndicator from './components/FreeSpaceIndicator'
|
import Footer from './components/Footer'
|
||||||
import Logout from './components/Logout'
|
import Logout from './components/Logout'
|
||||||
import SocketSubscriber from './components/SocketSubscriber'
|
import SocketSubscriber from './components/SocketSubscriber'
|
||||||
import ThemeToggler from './components/ThemeToggler'
|
import ThemeToggler from './components/ThemeToggler'
|
||||||
import { useI18n } from './hooks/useI18n'
|
import { useI18n } from './hooks/useI18n'
|
||||||
import Toaster from './providers/ToasterProvider'
|
import Toaster from './providers/ToasterProvider'
|
||||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const settings = useRecoilValue(settingsState)
|
const settings = useRecoilValue(settingsState)
|
||||||
const isConnected = useRecoilValue(connectedState)
|
|
||||||
|
|
||||||
const mode = settings.theme
|
const mode = settings.theme
|
||||||
const theme = useMemo(() =>
|
const theme = useMemo(() =>
|
||||||
@@ -81,21 +78,6 @@ export default function Layout() {
|
|||||||
>
|
>
|
||||||
{settings.appTitle}
|
{settings.appTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Suspense fallback={i18n.t('loadingLabel')}>
|
|
||||||
<FreeSpaceIndicator />
|
|
||||||
</Suspense>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginLeft: '4px',
|
|
||||||
gap: 3,
|
|
||||||
}}>
|
|
||||||
<SettingsEthernet />
|
|
||||||
<span>
|
|
||||||
{isConnected ? settings.serverAddr : i18n.t('notConnectedText')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<Drawer variant="permanent" open={open}>
|
<Drawer variant="permanent" open={open}>
|
||||||
@@ -134,7 +116,7 @@ export default function Layout() {
|
|||||||
}>
|
}>
|
||||||
<ListItemButton>
|
<ListItemButton>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<DownloadIcon />
|
<ArchiveIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
@@ -181,6 +163,7 @@ export default function Layout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Footer />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
languages:
|
languages:
|
||||||
english:
|
english:
|
||||||
urlInput: Video URL
|
urlInput: Video URL (one per line)
|
||||||
statusTitle: Status
|
statusTitle: Status
|
||||||
statusReady: Ready
|
statusReady: Ready
|
||||||
selectFormatButton: Select format
|
selectFormatButton: Select format
|
||||||
@@ -49,6 +49,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
german:
|
german:
|
||||||
urlInput: Video URL
|
urlInput: Video URL
|
||||||
statusTitle: Status
|
statusTitle: Status
|
||||||
@@ -98,6 +99,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Vorlagen Inhalt
|
templatesEditorContentLabel: Vorlagen Inhalt
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
french:
|
french:
|
||||||
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
||||||
statusTitle: Statut
|
statusTitle: Statut
|
||||||
@@ -149,8 +151,9 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
italian:
|
italian:
|
||||||
urlInput: URL Video
|
urlInput: URL Video (uno per linea)
|
||||||
statusTitle: Stato
|
statusTitle: Stato
|
||||||
startButton: Inizia
|
startButton: Inizia
|
||||||
statusReady: Pronto
|
statusReady: Pronto
|
||||||
@@ -197,6 +200,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Contentunto template
|
templatesEditorContentLabel: Contentunto template
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
chinese:
|
chinese:
|
||||||
urlInput: 视频 URL
|
urlInput: 视频 URL
|
||||||
statusTitle: 状态
|
statusTitle: 状态
|
||||||
@@ -246,6 +250,7 @@ languages:
|
|||||||
templatesEditorContentLabel: 模板内容
|
templatesEditorContentLabel: 模板内容
|
||||||
logsTitle: '日志'
|
logsTitle: '日志'
|
||||||
awaitingLogs: '正在等待日志…'
|
awaitingLogs: '正在等待日志…'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
spanish:
|
spanish:
|
||||||
urlInput: URL de YouTube u otro servicio compatible
|
urlInput: URL de YouTube u otro servicio compatible
|
||||||
statusTitle: Estado
|
statusTitle: Estado
|
||||||
@@ -293,6 +298,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
russian:
|
russian:
|
||||||
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
||||||
statusTitle: Статус
|
statusTitle: Статус
|
||||||
@@ -340,6 +346,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
korean:
|
korean:
|
||||||
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
||||||
statusTitle: 상태
|
statusTitle: 상태
|
||||||
@@ -387,6 +394,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
japanese:
|
japanese:
|
||||||
urlInput: YouTubeまたはサポート済み動画のURL
|
urlInput: YouTubeまたはサポート済み動画のURL
|
||||||
statusTitle: 状態
|
statusTitle: 状態
|
||||||
@@ -435,6 +443,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
catalan:
|
catalan:
|
||||||
urlInput: URL de YouTube o d'un altre servei compatible
|
urlInput: URL de YouTube o d'un altre servei compatible
|
||||||
statusTitle: Estat
|
statusTitle: Estat
|
||||||
@@ -482,6 +491,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
ukrainian:
|
ukrainian:
|
||||||
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
||||||
statusTitle: Статус
|
statusTitle: Статус
|
||||||
@@ -529,6 +539,7 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
polish:
|
polish:
|
||||||
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
||||||
statusTitle: Status
|
statusTitle: Status
|
||||||
@@ -576,3 +587,4 @@ languages:
|
|||||||
templatesEditorContentLabel: Template content
|
templatesEditorContentLabel: Template content
|
||||||
logsTitle: 'Logs'
|
logsTitle: 'Logs'
|
||||||
awaitingLogs: 'Awaiting logs...'
|
awaitingLogs: 'Awaiting logs...'
|
||||||
|
bulkDownload: 'Download files in a zip archive'
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { atom } from 'recoil'
|
|
||||||
import { DLMetadata } from '../types'
|
|
||||||
|
|
||||||
export const selectedFormatState = atom<Partial<DLMetadata>>({
|
|
||||||
key: 'selectedFormatState',
|
|
||||||
default: {},
|
|
||||||
})
|
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import { atom, selector } from 'recoil'
|
import { atom, selector } from 'recoil'
|
||||||
import { rpcClientState } from './rpc'
|
import { rpcClientState } from './rpc'
|
||||||
|
|
||||||
type StatusState = {
|
|
||||||
connected: boolean,
|
|
||||||
updated: boolean,
|
|
||||||
downloading: boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const connectedState = atom({
|
export const connectedState = atom({
|
||||||
key: 'connectedState',
|
key: 'connectedState',
|
||||||
default: false
|
default: false
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { atom } from 'recoil'
|
import { atom, selector } from 'recoil'
|
||||||
|
import { activeDownloadsState } from './downloads'
|
||||||
|
|
||||||
export const loadingAtom = atom({
|
export const loadingAtom = atom({
|
||||||
key: 'loadingAtom',
|
key: 'loadingAtom',
|
||||||
default: true
|
default: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const totalDownloadSpeedState = selector<number>({
|
||||||
|
key: 'totalDownloadSpeedState',
|
||||||
|
get: ({ get }) => get(activeDownloadsState)
|
||||||
|
.map(d => d.progress.speed)
|
||||||
|
.reduce((curr, next) => curr + next, 0)
|
||||||
|
})
|
||||||
@@ -16,8 +16,10 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
import { RPCResult } from '../types'
|
import { RPCResult } from '../types'
|
||||||
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils'
|
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
download: RPCResult
|
download: RPCResult
|
||||||
@@ -35,6 +37,8 @@ const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
||||||
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
|
|
||||||
const isCompleted = useCallback(
|
const isCompleted = useCallback(
|
||||||
() => download.progress.percentage === '-1',
|
() => download.progress.percentage === '-1',
|
||||||
[download.progress.percentage]
|
[download.progress.percentage]
|
||||||
@@ -47,6 +51,16 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
[download.progress.percentage, isCompleted]
|
[download.progress.percentage, isCompleted]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const viewFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardActionArea onClick={() => {
|
<CardActionArea onClick={() => {
|
||||||
@@ -61,14 +75,22 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
/> :
|
/> :
|
||||||
<Skeleton variant="rectangular" height={180} />
|
<Skeleton variant="rectangular" height={180} />
|
||||||
}
|
}
|
||||||
|
{download.progress.percentage ?
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={percentageToNumber()}
|
||||||
|
color={isCompleted() ? "success" : "primary"}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{download.info.title !== '' ?
|
{download.info.title !== '' ?
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
{ellipsis(download.info.title, 54)}
|
{ellipsis(download.info.title, 100)}
|
||||||
</Typography> :
|
</Typography> :
|
||||||
<Skeleton />
|
<Skeleton />
|
||||||
}
|
}
|
||||||
<Stack direction="row" spacing={1} py={2}>
|
<Stack direction="row" spacing={0.5} py={1}>
|
||||||
<Chip
|
<Chip
|
||||||
label={
|
label={
|
||||||
isCompleted()
|
isCompleted()
|
||||||
@@ -86,18 +108,10 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
{!isCompleted() ? formatSpeedMiB(download.progress.speed) : ''}
|
{!isCompleted() ? formatSpeedMiB(download.progress.speed) : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography>
|
<Typography>
|
||||||
{roundMiB(download.info.filesize_approx ?? 0)}
|
{formatSize(download.info.filesize_approx ?? 0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Resolution resolution={download.info.resolution} />
|
<Resolution resolution={download.info.resolution} />
|
||||||
</Stack>
|
</Stack>
|
||||||
{download.progress.percentage ?
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={percentageToNumber()}
|
|
||||||
color={isCompleted() ? "secondary" : "primary"}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
<CardActions>
|
<CardActions>
|
||||||
@@ -109,6 +123,26 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
>
|
>
|
||||||
{isCompleted() ? "Clear" : "Stop"}
|
{isCompleted() ? "Clear" : "Stop"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isCompleted() &&
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => downloadFile(download.output.savedFilePath)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => viewFile(download.output.savedFilePath)}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</CardActions>
|
</CardActions>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { useI18n } from '../hooks/useI18n'
|
|||||||
import { useRPC } from '../hooks/useRPC'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import { CliArguments } from '../lib/argsParser'
|
import { CliArguments } from '../lib/argsParser'
|
||||||
import type { DLMetadata } from '../types'
|
import type { DLMetadata } from '../types'
|
||||||
import { isValidURL, toFormatArgs } from '../utils'
|
import { toFormatArgs } from '../utils'
|
||||||
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
import ExtraDownloadOptions from './ExtraDownloadOptions'
|
||||||
|
|
||||||
const Transition = forwardRef(function Transition(
|
const Transition = forwardRef(function Transition(
|
||||||
@@ -80,7 +80,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [workingUrl, setWorkingUrl] = useState('')
|
|
||||||
|
|
||||||
const [isPlaylist, setIsPlaylist] = useState(false)
|
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||||
|
|
||||||
@@ -103,35 +102,36 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
/**
|
/**
|
||||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||||
*/
|
*/
|
||||||
const sendUrl = (immediate?: string) => {
|
const sendUrl = async (immediate?: string) => {
|
||||||
const codes = new Array<string>()
|
for (const line of url.split('\n')) {
|
||||||
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat)
|
const codes = new Array<string>()
|
||||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat)
|
||||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
|
||||||
|
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
|
||||||
|
|
||||||
client.download({
|
await new Promise(r => setTimeout(r, 10))
|
||||||
url: immediate || url || workingUrl,
|
await client.download({
|
||||||
args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
|
url: immediate || line,
|
||||||
pathOverride: downloadPath ?? '',
|
args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||||
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
pathOverride: downloadPath ?? '',
|
||||||
playlist: isPlaylist,
|
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
||||||
})
|
playlist: isPlaylist,
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resetInput()
|
||||||
|
setDownloadFormats(undefined)
|
||||||
|
onDownloadStart(immediate || line)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
setUrl('')
|
setUrl('')
|
||||||
setWorkingUrl('')
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
resetInput()
|
|
||||||
setDownloadFormats(undefined)
|
|
||||||
onDownloadStart(immediate || url || workingUrl)
|
|
||||||
}, 250)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrive url from input and display the formats selection view
|
* Retrive url from input and display the formats selection view
|
||||||
*/
|
*/
|
||||||
const sendUrlFormatSelection = () => {
|
const sendUrlFormatSelection = () => {
|
||||||
setWorkingUrl(url)
|
|
||||||
setUrl('')
|
setUrl('')
|
||||||
setPickedAudioFormat('')
|
setPickedAudioFormat('')
|
||||||
setPickedVideoFormat('')
|
setPickedVideoFormat('')
|
||||||
@@ -166,7 +166,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
|
|
||||||
file
|
file
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter(u => isValidURL(u))
|
|
||||||
.forEach(u => sendUrl(u))
|
.forEach(u => sendUrl(u))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +206,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
backgroundColor: (theme) => theme.palette.background.default,
|
backgroundColor: (theme) => theme.palette.background.default,
|
||||||
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||||
}}>
|
}}>
|
||||||
<Container sx={{ my: 4 }} >
|
<Container sx={{ my: 4 }}>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Paper
|
<Paper
|
||||||
@@ -220,6 +219,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
>
|
>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<TextField
|
<TextField
|
||||||
|
multiline
|
||||||
fullWidth
|
fullWidth
|
||||||
ref={urlInputRef}
|
ref={urlInputRef}
|
||||||
label={i18n.t('urlInput')}
|
label={i18n.t('urlInput')}
|
||||||
|
|||||||
@@ -4,31 +4,24 @@ import { loadingDownloadsState } from '../atoms/downloads'
|
|||||||
import { listViewState } from '../atoms/settings'
|
import { listViewState } from '../atoms/settings'
|
||||||
import { loadingAtom } from '../atoms/ui'
|
import { loadingAtom } from '../atoms/ui'
|
||||||
import DownloadsCardView from './DownloadsCardView'
|
import DownloadsCardView from './DownloadsCardView'
|
||||||
import DownloadsListView from './DownloadsListView'
|
import DownloadsTableView from './DownloadsTableView'
|
||||||
|
|
||||||
const Downloads: React.FC = () => {
|
const Downloads: React.FC = () => {
|
||||||
const listView = useRecoilValue(listViewState)
|
const tableView = useRecoilValue(listViewState)
|
||||||
const loadingDownloads = useRecoilValue(loadingDownloadsState)
|
const loadingDownloads = useRecoilValue(loadingDownloadsState)
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useRecoilState(loadingAtom)
|
const [isLoading, setIsLoading] = useRecoilState(loadingAtom)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadingDownloads) {
|
if (loadingDownloads) {
|
||||||
setIsLoading(true)
|
return setIsLoading(true)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, [loadingDownloads, isLoading])
|
}, [loadingDownloads, isLoading])
|
||||||
|
|
||||||
if (listView) {
|
if (tableView) return <DownloadsTableView />
|
||||||
return (
|
|
||||||
<DownloadsListView />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return <DownloadsCardView />
|
||||||
<DownloadsCardView />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Downloads
|
export default Downloads
|
||||||
@@ -16,10 +16,10 @@ const DownloadsCardView: React.FC = () => {
|
|||||||
const abort = (id: string) => client.kill(id)
|
const abort = (id: string) => client.kill(id)
|
||||||
|
|
||||||
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, xl: 12 }} pt={2}>
|
||||||
{
|
{
|
||||||
downloads.map(download => (
|
downloads.map(download => (
|
||||||
<Grid item xs={4} sm={8} md={6} key={download.id}>
|
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
|
||||||
<DownloadCard
|
<DownloadCard
|
||||||
download={download}
|
download={download}
|
||||||
onStop={() => abort(download.id)}
|
onStop={() => abort(download.id)}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Grid,
|
|
||||||
LinearProgress,
|
|
||||||
Paper,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Typography
|
|
||||||
} from "@mui/material"
|
|
||||||
import { useRecoilValue } from 'recoil'
|
|
||||||
import { activeDownloadsState } from '../atoms/downloads'
|
|
||||||
import { useRPC } from '../hooks/useRPC'
|
|
||||||
import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
|
|
||||||
|
|
||||||
|
|
||||||
const DownloadsListView: React.FC = () => {
|
|
||||||
const downloads = useRecoilValue(activeDownloadsState)
|
|
||||||
|
|
||||||
const { client } = useRPC()
|
|
||||||
|
|
||||||
const abort = (id: string) => client.kill(id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<TableContainer
|
|
||||||
component={Paper}
|
|
||||||
sx={{ minHeight: '100%' }}
|
|
||||||
elevation={2}
|
|
||||||
hidden={downloads.length === 0}
|
|
||||||
>
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<Typography fontWeight={500} fontSize={15}>Title</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography fontWeight={500} fontSize={15}>Progress</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography fontWeight={500} fontSize={15}>Speed</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography fontWeight={500} fontSize={15}>Size</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography fontWeight={500} fontSize={15}>Actions</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{
|
|
||||||
downloads.map(download => (
|
|
||||||
<TableRow key={download.id}>
|
|
||||||
<TableCell>{ellipsis(download.info.title, 80)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<LinearProgress
|
|
||||||
value={
|
|
||||||
download.progress.percentage === '-1'
|
|
||||||
? 100
|
|
||||||
: Number(download.progress.percentage.replace('%', ''))
|
|
||||||
}
|
|
||||||
variant={
|
|
||||||
download.progress.process_status === 0
|
|
||||||
? 'indeterminate'
|
|
||||||
: 'determinate'
|
|
||||||
}
|
|
||||||
color={download.progress.percentage === '-1' ? 'success' : 'primary'}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatSpeedMiB(download.progress.speed)}</TableCell>
|
|
||||||
<TableCell>{roundMiB(download.info.filesize_approx ?? 0)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
onClick={() => abort(download.id)}
|
|
||||||
>
|
|
||||||
{download.progress.percentage === '-1' ? 'Remove' : 'Stop'}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DownloadsListView
|
|
||||||
159
frontend/src/components/DownloadsTableView.tsx
Normal file
159
frontend/src/components/DownloadsTableView.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
|
import DownloadDoneIcon from '@mui/icons-material/DownloadDone'
|
||||||
|
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||||
|
import SmartDisplayIcon from '@mui/icons-material/SmartDisplay'
|
||||||
|
import StopCircleIcon from '@mui/icons-material/StopCircle'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
ButtonGroup,
|
||||||
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
|
LinearProgressProps,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material"
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { useRPC } from '../hooks/useRPC'
|
||||||
|
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
|
||||||
|
|
||||||
|
function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Box sx={{ width: '100%', mr: 1 }}>
|
||||||
|
<LinearProgress variant="determinate" {...props} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 35 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">{`${Math.round(
|
||||||
|
props.value,
|
||||||
|
)}%`}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadsTableView: React.FC = () => {
|
||||||
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
|
const downloads = useRecoilValue(activeDownloadsState)
|
||||||
|
|
||||||
|
const { client } = useRPC()
|
||||||
|
|
||||||
|
const abort = (id: string) => client.kill(id)
|
||||||
|
|
||||||
|
const viewFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer
|
||||||
|
sx={{ minHeight: '80vh', mt: 4 }}
|
||||||
|
hidden={downloads.length === 0}
|
||||||
|
>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell width={8}>
|
||||||
|
<Typography fontWeight={500} fontSize={13}>Status</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography fontWeight={500} fontSize={13}>Title</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Typography fontWeight={500} fontSize={13}>Speed</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" width={200}>
|
||||||
|
<Typography fontWeight={500} fontSize={13}>Progress</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Typography fontWeight={500} fontSize={13}>Size</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" width={180}>
|
||||||
|
<Typography fontWeight={500} fontSize={13}>Added on</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" width={8}>
|
||||||
|
<Typography fontWeight={500} fontSize={13}>Actions</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{
|
||||||
|
downloads.map(download => (
|
||||||
|
<TableRow key={download.id}>
|
||||||
|
<TableCell>
|
||||||
|
{download.progress.percentage === '-1'
|
||||||
|
? <DownloadDoneIcon color="primary" />
|
||||||
|
: <DownloadIcon color="primary" />
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{download.info.title}</TableCell>
|
||||||
|
<TableCell align="right">{formatSpeedMiB(download.progress.speed)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<LinearProgressWithLabel
|
||||||
|
sx={{ height: '16px' }}
|
||||||
|
value={
|
||||||
|
download.progress.percentage === '-1'
|
||||||
|
? 100
|
||||||
|
: Number(download.progress.percentage.replace('%', ''))
|
||||||
|
}
|
||||||
|
variant={
|
||||||
|
download.progress.process_status === 0
|
||||||
|
? 'indeterminate'
|
||||||
|
: 'determinate'
|
||||||
|
}
|
||||||
|
color={download.progress.percentage === '-1' ? 'primary' : 'primary'}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{formatSize(download.info.filesize_approx ?? 0)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{new Date(download.info.created_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<ButtonGroup>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => abort(download.id)}
|
||||||
|
>
|
||||||
|
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}
|
||||||
|
|
||||||
|
</IconButton>
|
||||||
|
{download.progress.percentage === '-1' &&
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => viewFile(download.output.savedFilePath)}
|
||||||
|
>
|
||||||
|
<SmartDisplayIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => downloadFile(download.output.savedFilePath)}
|
||||||
|
>
|
||||||
|
<FileDownloadIcon />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</ButtonGroup>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadsTableView
|
||||||
69
frontend/src/components/Footer.tsx
Normal file
69
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
||||||
|
import { AppBar, Chip, Divider, Toolbar } from '@mui/material'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { settingsState } from '../atoms/settings'
|
||||||
|
import { connectedState } from '../atoms/status'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import FreeSpaceIndicator from './FreeSpaceIndicator'
|
||||||
|
import VersionIndicator from './VersionIndicator'
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
|
import { totalDownloadSpeedState } from '../atoms/ui'
|
||||||
|
import { formatSpeedMiB } from '../utils'
|
||||||
|
|
||||||
|
const Footer: React.FC = () => {
|
||||||
|
const settings = useRecoilValue(settingsState)
|
||||||
|
const isConnected = useRecoilValue(connectedState)
|
||||||
|
const totalDownloadSpeed = useRecoilValue(totalDownloadSpeedState)
|
||||||
|
|
||||||
|
const mode = settings.theme
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar position="fixed" color="default" sx={{
|
||||||
|
top: 'auto',
|
||||||
|
bottom: 0,
|
||||||
|
height: 48,
|
||||||
|
zIndex: 1200,
|
||||||
|
borderTop: mode === 'light'
|
||||||
|
? '1px solid rgba(0, 0, 0, 0.12)'
|
||||||
|
: '1px solid rgba(255, 255, 255, 0.12)',
|
||||||
|
}}>
|
||||||
|
<Toolbar sx={{
|
||||||
|
paddingBottom: 2,
|
||||||
|
fontSize: 14,
|
||||||
|
display: 'flex', gap: 1, justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<Chip label="RPC v3.0.6" variant="outlined" size="small" />
|
||||||
|
<VersionIndicator />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginRight: 'px',
|
||||||
|
gap: 3,
|
||||||
|
}}>
|
||||||
|
<DownloadIcon />
|
||||||
|
<span>
|
||||||
|
{formatSpeedMiB(totalDownloadSpeed)}
|
||||||
|
</span>
|
||||||
|
<Divider orientation="vertical" flexItem />
|
||||||
|
<SettingsEthernet />
|
||||||
|
<span>
|
||||||
|
{isConnected ? settings.serverAddr : i18n.t('notConnectedText')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Divider orientation="vertical" flexItem />
|
||||||
|
<Suspense fallback={i18n.t('loadingLabel')}>
|
||||||
|
<FreeSpaceIndicator />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import StorageIcon from '@mui/icons-material/Storage'
|
import StorageIcon from '@mui/icons-material/Storage'
|
||||||
import { useRecoilValue } from 'recoil'
|
import { useRecoilValue } from 'recoil'
|
||||||
import { freeSpaceBytesState } from '../atoms/status'
|
import { freeSpaceBytesState } from '../atoms/status'
|
||||||
import { formatGiB } from '../utils'
|
import { formatSize } from '../utils'
|
||||||
|
|
||||||
const FreeSpaceIndicator = () => {
|
const FreeSpaceIndicator = () => {
|
||||||
const freeSpace = useRecoilValue(freeSpaceBytesState)
|
const freeSpace = useRecoilValue(freeSpaceBytesState)
|
||||||
@@ -15,7 +15,7 @@ const FreeSpaceIndicator = () => {
|
|||||||
}}>
|
}}>
|
||||||
<StorageIcon />
|
<StorageIcon />
|
||||||
<span>
|
<span>
|
||||||
{formatGiB(freeSpace)}
|
{formatSize(freeSpace)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const HomeActions: React.FC = () => {
|
|||||||
setOpenDownload(false)
|
setOpenDownload(false)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
}}
|
}}
|
||||||
|
// TODO: handle optimistic UI update
|
||||||
onDownloadStart={(url) => {
|
onDownloadStart={(url) => {
|
||||||
pushMessage(`Requested ${url}`, 'info')
|
pushMessage(`Requested ${url}`, 'info')
|
||||||
setOpenDownload(false)
|
setOpenDownload(false)
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'
|
|||||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
|
import BuildCircleIcon from '@mui/icons-material/BuildCircle'
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||||
|
import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'
|
||||||
|
import FolderZipIcon from '@mui/icons-material/FolderZip'
|
||||||
import {
|
import {
|
||||||
SpeedDial,
|
SpeedDial,
|
||||||
SpeedDialAction,
|
SpeedDialAction,
|
||||||
SpeedDialIcon
|
SpeedDialIcon
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useRecoilState } from 'recoil'
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
import { listViewState } from '../atoms/settings'
|
import { listViewState, serverURL } from '../atoms/settings'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { useRPC } from '../hooks/useRPC'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
|
|
||||||
@@ -18,7 +20,8 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
||||||
const [, setListView] = useRecoilState(listViewState)
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
|
const [listView, setListView] = useRecoilState(listViewState)
|
||||||
|
|
||||||
const { i18n } = useI18n()
|
const { i18n } = useI18n()
|
||||||
const { client } = useRPC()
|
const { client } = useRPC()
|
||||||
@@ -28,14 +31,19 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
|
|||||||
return (
|
return (
|
||||||
<SpeedDial
|
<SpeedDial
|
||||||
ariaLabel="Home speed dial"
|
ariaLabel="Home speed dial"
|
||||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||||
icon={<SpeedDialIcon />}
|
icon={<SpeedDialIcon />}
|
||||||
>
|
>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
icon={<FormatListBulleted />}
|
icon={listView ? <ViewAgendaIcon /> : <FormatListBulleted />}
|
||||||
tooltipTitle={`Table view`}
|
tooltipTitle={listView ? 'Card view' : 'Table view'}
|
||||||
onClick={() => setListView(state => !state)}
|
onClick={() => setListView(state => !state)}
|
||||||
/>
|
/>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<FolderZipIcon />}
|
||||||
|
tooltipTitle={i18n.t('bulkDownload')}
|
||||||
|
onClick={() => window.open(`${serverAddr}/archive/bulk`)}
|
||||||
|
/>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
icon={<DeleteForeverIcon />}
|
icon={<DeleteForeverIcon />}
|
||||||
tooltipTitle={i18n.t('abortAllButton')}
|
tooltipTitle={i18n.t('abortAllButton')}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Box, CircularProgress, Container, Paper, Typography } from '@mui/material'
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useRecoilValue } from 'recoil'
|
import { useRecoilValue } from 'recoil'
|
||||||
import { serverURL } from '../atoms/settings'
|
import { serverURL } from '../atoms/settings'
|
||||||
@@ -7,14 +6,15 @@ import { useI18n } from '../hooks/useI18n'
|
|||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
const LogTerminal: React.FC = () => {
|
const LogTerminal: React.FC = () => {
|
||||||
|
const [logBuffer, setLogBuffer] = useState<string[]>([])
|
||||||
|
const [isConnecting, setIsConnecting] = useState(true)
|
||||||
|
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const serverAddr = useRecoilValue(serverURL)
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
|
|
||||||
const { i18n } = useI18n()
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
const [logBuffer, setLogBuffer] = useState<string[]>([])
|
|
||||||
|
|
||||||
const boxRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const eventSource = useMemo(
|
const eventSource = useMemo(
|
||||||
() => new EventSource(`${serverAddr}/log/sse?token=${token}`),
|
() => new EventSource(`${serverAddr}/log/sse?token=${token}`),
|
||||||
[serverAddr]
|
[serverAddr]
|
||||||
@@ -23,7 +23,7 @@ const LogTerminal: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventSource.addEventListener('log', event => {
|
eventSource.addEventListener('log', event => {
|
||||||
const msg: string[] = JSON.parse(event.data)
|
const msg: string[] = JSON.parse(event.data)
|
||||||
setLogBuffer(buff => [...buff, ...msg].slice(-100))
|
setLogBuffer(buff => [...buff, ...msg].slice(-500))
|
||||||
|
|
||||||
boxRef.current?.scrollTo(0, boxRef.current.scrollHeight)
|
boxRef.current?.scrollTo(0, boxRef.current.scrollHeight)
|
||||||
})
|
})
|
||||||
@@ -32,59 +32,51 @@ const LogTerminal: React.FC = () => {
|
|||||||
return () => eventSource.close()
|
return () => eventSource.close()
|
||||||
}, [eventSource])
|
}, [eventSource])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
eventSource.onopen = () => setIsConnecting(false)
|
||||||
|
}, [eventSource])
|
||||||
|
|
||||||
const logEntryStyle = (data: string) => {
|
const logEntryStyle = (data: string) => {
|
||||||
|
const sx = {}
|
||||||
|
|
||||||
if (data.includes("level=ERROR")) {
|
if (data.includes("level=ERROR")) {
|
||||||
return { color: 'red' }
|
return { ...sx, color: 'red' }
|
||||||
}
|
}
|
||||||
if (data.includes("level=WARN")) {
|
if (data.includes("level=WARN")) {
|
||||||
return { color: 'orange' }
|
return { ...sx, color: 'orange' }
|
||||||
}
|
}
|
||||||
return {}
|
|
||||||
|
return sx
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
|
||||||
<Paper
|
<div
|
||||||
sx={{
|
ref={boxRef}
|
||||||
p: 2.5,
|
style={{
|
||||||
display: 'flex',
|
fontFamily: 'Roboto Mono',
|
||||||
flexDirection: 'column',
|
height: '70.5vh',
|
||||||
}}
|
overflowY: 'auto',
|
||||||
>
|
overflowX: 'auto',
|
||||||
<Typography py={1} variant="h5" color="primary">
|
fontSize: '13.5px',
|
||||||
{i18n.t('logsTitle')}
|
fontWeight: '600',
|
||||||
</Typography>
|
backgroundColor: 'black',
|
||||||
{(logBuffer.length === 0) && <Box sx={{
|
color: 'white',
|
||||||
display: 'flex',
|
padding: '0.5rem',
|
||||||
flexDirection: 'column',
|
borderRadius: '0.25rem'
|
||||||
justifyItems: 'center',
|
}}
|
||||||
alignItems: 'center',
|
>
|
||||||
gap: 1
|
{isConnecting ? <div>{'Connecting...'}</div> : <div>{'Connected!'}</div>}
|
||||||
}}>
|
|
||||||
<CircularProgress color="primary" size={32} />
|
{logBuffer.length === 0 && <div>{i18n.t('awaitingLogs')}</div>}
|
||||||
<Typography py={1} variant="subtitle2" >
|
|
||||||
{i18n.t('awaitingLogs')}
|
{logBuffer.map((log, idx) => (
|
||||||
</Typography>
|
<div key={idx} style={logEntryStyle(log)}>
|
||||||
</Box>
|
{log}
|
||||||
}
|
</div>
|
||||||
<Box
|
))}
|
||||||
ref={boxRef}
|
</div>
|
||||||
sx={{
|
|
||||||
fontFamily: 'Roboto Mono',
|
|
||||||
height: '75.5vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'auto',
|
|
||||||
fontSize: '15px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{logBuffer.map((log, idx) => (
|
|
||||||
<Box key={idx} sx={logEntryStyle(log)}>
|
|
||||||
{log}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Container >
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const SocketSubscriber: React.FC<Props> = () => {
|
|||||||
.filter(f => !!f.info.url).sort((a, b) => datetimeCompareFunc(
|
.filter(f => !!f.info.url).sort((a, b) => datetimeCompareFunc(
|
||||||
b.info.created_at,
|
b.info.created_at,
|
||||||
a.info.created_at,
|
a.info.created_at,
|
||||||
))
|
)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
38
frontend/src/components/VersionIndicator.tsx
Normal file
38
frontend/src/components/VersionIndicator.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Chip, CircularProgress } from '@mui/material'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
|
||||||
|
const VersionIndicator: React.FC = () => {
|
||||||
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
|
|
||||||
|
const [version, setVersion] = useState('')
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const fetchVersion = async () => {
|
||||||
|
const res = await fetch(`${serverAddr}/api/v1/version`, {
|
||||||
|
headers: {
|
||||||
|
'X-Authentication': localStorage.getItem('token') ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return pushMessage(await res.text(), 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
setVersion(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVersion()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
version
|
||||||
|
? <Chip label={`yt-dlp v${version}`} variant="outlined" size="small" />
|
||||||
|
: <CircularProgress size={15} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionIndicator
|
||||||
@@ -45,6 +45,9 @@ export type RPCResult = Readonly<{
|
|||||||
id: string
|
id: string
|
||||||
progress: DownloadProgress
|
progress: DownloadProgress
|
||||||
info: DownloadInfo
|
info: DownloadInfo
|
||||||
|
output: {
|
||||||
|
savedFilePath: string
|
||||||
|
}
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export type RPCParams = {
|
export type RPCParams = {
|
||||||
|
|||||||
@@ -20,17 +20,10 @@ export function validateDomain(url: string): boolean {
|
|||||||
return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug)
|
return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidURL(url: string): boolean {
|
export const ellipsis = (str: string, lim: number) =>
|
||||||
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
|
str.length > lim
|
||||||
return urlRegex.test(url)
|
? `${str.substring(0, lim)}...`
|
||||||
}
|
: str
|
||||||
|
|
||||||
export function ellipsis(str: string, lim: number): string {
|
|
||||||
if (str) {
|
|
||||||
return str.length > lim ? `${str.substring(0, lim)}...` : str
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toFormatArgs(codes: string[]): string {
|
export function toFormatArgs(codes: string[]): string {
|
||||||
if (codes.length > 1) {
|
if (codes.length > 1) {
|
||||||
@@ -42,14 +35,21 @@ export function toFormatArgs(codes: string[]): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatGiB = (bytes: number) =>
|
export function formatSize(bytes: number): string {
|
||||||
`${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
const threshold = 1024
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
||||||
|
|
||||||
export const roundMiB = (bytes: number) =>
|
let i = 0
|
||||||
`${(bytes / 1_000_000).toFixed(2)} MiB`
|
while (bytes >= threshold) {
|
||||||
|
bytes /= threshold
|
||||||
|
i = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${bytes.toFixed(i == 0 ? 0 : 2)} ${units.at(i)}`
|
||||||
|
}
|
||||||
|
|
||||||
export const formatSpeedMiB = (val: number) =>
|
export const formatSpeedMiB = (val: number) =>
|
||||||
`${roundMiB(val)}/s`
|
`${(val / 1_048_576).toFixed(2)} MiB/s`
|
||||||
|
|
||||||
export const datetimeCompareFunc = (a: string, b: string) =>
|
export const datetimeCompareFunc = (a: string, b: string) =>
|
||||||
new Date(a).getTime() - new Date(b).getTime()
|
new Date(a).getTime() - new Date(b).getTime()
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { useToast } from '../hooks/toast'
|
|||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { ffetch } from '../lib/httpClient'
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { DirectoryEntry } from '../types'
|
import { DirectoryEntry } from '../types'
|
||||||
import { base64URLEncode, roundMiB } from '../utils'
|
import { base64URLEncode, formatSize } from '../utils'
|
||||||
|
|
||||||
export default function Downloaded() {
|
export default function Downloaded() {
|
||||||
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
||||||
@@ -182,7 +182,7 @@ export default function Downloaded() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
maxWidth="lg"
|
maxWidth="xl"
|
||||||
sx={{ mt: 4, mb: 4, height: '100%' }}
|
sx={{ mt: 4, mb: 4, height: '100%' }}
|
||||||
onClick={() => setShowMenu(false)}
|
onClick={() => setShowMenu(false)}
|
||||||
>
|
>
|
||||||
@@ -237,7 +237,7 @@ export default function Downloaded() {
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
component="span"
|
component="span"
|
||||||
>
|
>
|
||||||
{roundMiB(file.size)}
|
{formatSize(file.size)}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
{!file.isDirectory && <>
|
{!file.isDirectory && <>
|
||||||
@@ -274,8 +274,8 @@ export default function Downloaded() {
|
|||||||
</List>
|
</List>
|
||||||
</Paper>
|
</Paper>
|
||||||
<SpeedDial
|
<SpeedDial
|
||||||
ariaLabel="SpeedDial basic example"
|
ariaLabel='archive actions'
|
||||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||||
icon={<SpeedDialIcon />}
|
icon={<SpeedDialIcon />}
|
||||||
>
|
>
|
||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Splash from '../components/Splash'
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
<Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}>
|
||||||
<LoadingBackdrop />
|
<LoadingBackdrop />
|
||||||
<Splash />
|
<Splash />
|
||||||
<Downloads />
|
<Downloads />
|
||||||
|
|||||||
@@ -130,16 +130,16 @@ export default function Settings() {
|
|||||||
* Updates yt-dlp binary via RPC
|
* Updates yt-dlp binary via RPC
|
||||||
*/
|
*/
|
||||||
const updateBinary = () => {
|
const updateBinary = () => {
|
||||||
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated')))
|
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={12} md={12} lg={12}>
|
<Grid item xs={12} md={12} lg={12}>
|
||||||
<Paper
|
<Paper
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: 2.5,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
minHeight: 240,
|
minHeight: 240,
|
||||||
@@ -204,7 +204,7 @@ export default function Settings() {
|
|||||||
label={i18n.t('languageSelect')}
|
label={i18n.t('languageSelect')}
|
||||||
onChange={handleLanguageChange}
|
onChange={handleLanguageChange}
|
||||||
>
|
>
|
||||||
{languages.map(l => (
|
{languages.toSorted((a, b) => a.localeCompare(b)).map(l => (
|
||||||
<MenuItem value={l} key={l}>
|
<MenuItem value={l} key={l}>
|
||||||
{capitalize(l)}
|
{capitalize(l)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
|
import { Container, Paper, Typography } from '@mui/material'
|
||||||
import LogTerminal from '../components/LogTerminal'
|
import LogTerminal from '../components/LogTerminal'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
|
||||||
const Terminal: React.FC = () => {
|
const Terminal: React.FC = () => {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LogTerminal />
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography pb={2} variant="h5" color="primary">
|
||||||
|
{i18n.t('logsTitle')}
|
||||||
|
</Typography>
|
||||||
|
<LogTerminal />
|
||||||
|
</Paper>
|
||||||
|
</Container >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
go.mod
41
go.mod
@@ -1,43 +1,38 @@
|
|||||||
module github.com/marcopeocchi/yt-dlp-web-ui
|
module github.com/marcopeocchi/yt-dlp-web-ui
|
||||||
|
|
||||||
go 1.20
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.0.11
|
github.com/go-chi/chi/v5 v5.0.12
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.5.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.1
|
github.com/gorilla/websocket v1.5.1
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
|
|
||||||
github.com/reactivex/rxgo/v2 v2.5.0
|
github.com/reactivex/rxgo/v2 v2.5.0
|
||||||
golang.org/x/sys v0.15.0
|
golang.org/x/sys v0.18.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.28.0
|
modernc.org/sqlite v1.29.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cenkalti/backoff/v4 v4.0.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.0 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/emirpasic/gods v1.12.0 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/stretchr/objx v0.1.0 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/stretchr/testify v1.4.0 // indirect
|
github.com/stretchr/testify v1.9.0 // indirect
|
||||||
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 // indirect
|
github.com/teivah/onecontext v1.3.0 // indirect
|
||||||
golang.org/x/mod v0.14.0 // indirect
|
golang.org/x/net v0.22.0 // indirect
|
||||||
golang.org/x/net v0.19.0 // indirect
|
golang.org/x/sync v0.6.0 // indirect
|
||||||
golang.org/x/tools v0.16.1 // indirect
|
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
|
||||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
modernc.org/libc v1.47.0 // indirect
|
||||||
lukechampine.com/uint128 v1.3.0 // indirect
|
|
||||||
modernc.org/cc/v3 v3.41.0 // indirect
|
|
||||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
|
||||||
modernc.org/libc v1.38.0 // indirect
|
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.7.2 // indirect
|
modernc.org/memory v1.7.2 // indirect
|
||||||
modernc.org/opt v0.1.3 // indirect
|
|
||||||
modernc.org/strutil v1.2.0 // indirect
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
modernc.org/token v1.1.0 // indirect
|
modernc.org/token v1.1.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
93
go.sum
93
go.sum
@@ -1,100 +1,109 @@
|
|||||||
github.com/cenkalti/backoff/v4 v4.0.0 h1:6VeaLF9aI+MAUQ95106HwWzYZgJJpZ4stumjj6RFYAU=
|
|
||||||
github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
|
github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
|
||||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||||
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
|
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4=
|
github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4=
|
||||||
github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU=
|
github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 h1:BLNsFR8l/hj/oGjnJXkd4Vi3s4kQD3/3x8HSAE4bzN0=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ=
|
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ=
|
||||||
|
github.com/teivah/onecontext v1.3.0 h1:tbikMhAlo6VhAuEGCvhc8HlTnpX4xTNPTOseWuhO1J0=
|
||||||
|
github.com/teivah/onecontext v1.3.0/go.mod h1:hoW1nmdPVK/0jrvGtcx8sCKYs2PiS4z0zzfdeuEVyb0=
|
||||||
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
|
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
|
||||||
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
modernc.org/cc/v4 v4.19.5 h1:QlsZyQ1zf78DGeqnQ9ILi9hXyMdoC5e1qoGNUyBjHQw=
|
||||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
modernc.org/cc/v4 v4.19.5/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
|
modernc.org/ccgo/v4 v4.13.0 h1:99E8QHRoPrXN8VpS0zgAgJ5nSjpXrPKpsJIMvGL/2Oc=
|
||||||
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
|
modernc.org/ccgo/v4 v4.13.0/go.mod h1:Td6RI9W9G2ZpKHaJ7UeGEiB2aIpoDqLBnm4wtkbJTbQ=
|
||||||
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
modernc.org/libc v1.38.0 h1:o4Lpk0zNDSdsjfEXnF1FGXWQ9PDi1NOdWcLP5n13FGo=
|
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
|
||||||
modernc.org/libc v1.38.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
|
modernc.org/libc v1.47.0 h1:BXrzId9fOOkBtS+uFQ5aZyVGmt7WcSEPrXF5Kwsho90=
|
||||||
|
modernc.org/libc v1.47.0/go.mod h1:gzCncw0a74aCiVqHeWAYHHaW//fkSHHS/3S/gfhLlCI=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE=
|
||||||
|
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
|
||||||
|
|||||||
15
main.go
15
main.go
@@ -30,6 +30,9 @@ var (
|
|||||||
userFromEnv = os.Getenv("USERNAME")
|
userFromEnv = os.Getenv("USERNAME")
|
||||||
passFromEnv = os.Getenv("PASSWORD")
|
passFromEnv = os.Getenv("PASSWORD")
|
||||||
|
|
||||||
|
logFile string
|
||||||
|
enableFileLogging bool
|
||||||
|
|
||||||
//go:embed frontend/dist/index.html
|
//go:embed frontend/dist/index.html
|
||||||
//go:embed frontend/dist/assets/*
|
//go:embed frontend/dist/assets/*
|
||||||
frontend embed.FS
|
frontend embed.FS
|
||||||
@@ -47,6 +50,9 @@ func init() {
|
|||||||
flag.StringVar(&sessionFilePath, "session", ".", "session file path")
|
flag.StringVar(&sessionFilePath, "session", ".", "session file path")
|
||||||
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
|
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
|
||||||
|
|
||||||
|
flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file")
|
||||||
|
flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location")
|
||||||
|
|
||||||
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
|
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
|
||||||
flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
|
flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
|
||||||
flag.StringVar(&password, "pass", passFromEnv, "Password required for auth")
|
flag.StringVar(&password, "pass", passFromEnv, "Password required for auth")
|
||||||
@@ -79,5 +85,12 @@ func main() {
|
|||||||
log.Println(cli.BgRed, "config", cli.Reset, err)
|
log.Println(cli.BgRed, "config", cli.Reset, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server.RunBlocking(c.Host, c.Port, frontend, localDatabasePath)
|
server.RunBlocking(&server.RunConfig{
|
||||||
|
Host: c.Host,
|
||||||
|
Port: c.Port,
|
||||||
|
App: frontend,
|
||||||
|
DBPath: localDatabasePath,
|
||||||
|
FileLogging: enableFileLogging,
|
||||||
|
LogFile: logFile,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func Instance() *Config {
|
|||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialises the Config struct given its config file
|
||||||
func (c *Config) LoadFile(filename string) error {
|
func (c *Config) LoadFile(filename string) error {
|
||||||
fd, err := os.Open(filename)
|
fd, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Run the table migration
|
||||||
func AutoMigrate(ctx context.Context, db *sql.DB) error {
|
func AutoMigrate(ctx context.Context, db *sql.DB) error {
|
||||||
conn, err := db.Conn(ctx)
|
conn, err := db.Conn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
@@ -8,15 +10,22 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/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/internal"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
File based operation handlers (should be moved to rest/handlers.go) or in
|
||||||
|
a entirely self-contained package
|
||||||
|
*/
|
||||||
|
|
||||||
type DirectoryEntry struct {
|
type DirectoryEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -189,3 +198,54 @@ func DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BulkDownload(mdb *internal.MemoryDB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ps := slices.DeleteFunc(*mdb.All(), func(e internal.ProcessResponse) bool {
|
||||||
|
return e.Progress.Status != internal.StatusCompleted
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(ps) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
buff bytes.Buffer
|
||||||
|
zipWriter = zip.NewWriter(&buff)
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, p := range ps {
|
||||||
|
wr, err := zipWriter.Create(filepath.Base(p.Output.SavedFilePath))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fd, err := os.Open(p.Output.SavedFilePath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(wr, fd)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := zipWriter.Close()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Add(
|
||||||
|
"Content-Disposition",
|
||||||
|
"inline; filename=download-archive-"+time.Now().Format(time.RFC3339)+".zip",
|
||||||
|
)
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
|
||||||
|
io.Copy(w, &buff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ type LoginRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Login(w http.ResponseWriter, r *http.Request) {
|
func Login(w http.ResponseWriter, r *http.Request) {
|
||||||
req := new(LoginRequest)
|
var req LoginRequest
|
||||||
err := json.NewDecoder(r.Body).Decode(req)
|
|
||||||
if err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ package internal
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// Used to unmarshall yt-dlp progress
|
||||||
|
type ProgressTemplate struct {
|
||||||
|
Percentage string `json:"percentage"`
|
||||||
|
Speed float32 `json:"speed"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Eta float32 `json:"eta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines where and how the download needs to be saved
|
||||||
|
type DownloadOutput struct {
|
||||||
|
Path string
|
||||||
|
Filename string
|
||||||
|
SavedFilePath string `json:"savedFilePath"`
|
||||||
|
}
|
||||||
|
|
||||||
// Progress for the Running call
|
// Progress for the Running call
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
Status int `json:"process_status"`
|
Status int `json:"process_status"`
|
||||||
@@ -79,6 +94,7 @@ type SetCookiesRequest struct {
|
|||||||
Cookies string `json:"cookies"`
|
Cookies string `json:"cookies"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// represents a user defined collection of yt-dlp arguments
|
||||||
type CustomTemplate struct {
|
type CustomTemplate struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -35,33 +34,6 @@ func (m *MemoryDB) Set(process *Process) string {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update a process info/metadata, given the process id
|
|
||||||
//
|
|
||||||
// Deprecated: will be removed anytime soon.
|
|
||||||
func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error {
|
|
||||||
entry, ok := m.table.Load(id)
|
|
||||||
if ok {
|
|
||||||
entry.(*Process).Info = info
|
|
||||||
m.table.Store(id, entry)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("can't update row with id %s", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update a process progress data, given the process id
|
|
||||||
// Used for updating completition percentage or ETA.
|
|
||||||
//
|
|
||||||
// Deprecated: will be removed anytime soon.
|
|
||||||
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
|
|
||||||
entry, ok := m.table.Load(id)
|
|
||||||
if ok {
|
|
||||||
entry.(*Process).Progress = progress
|
|
||||||
m.table.Store(id, entry)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("can't update row with id %s", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removes a process progress, given the process id
|
// Removes a process progress, given the process id
|
||||||
func (m *MemoryDB) Delete(id string) {
|
func (m *MemoryDB) Delete(id string) {
|
||||||
m.table.Delete(id)
|
m.table.Delete(id)
|
||||||
@@ -92,7 +64,7 @@ func (m *MemoryDB) All() *[]ProcessResponse {
|
|||||||
return &running
|
return &running
|
||||||
}
|
}
|
||||||
|
|
||||||
// WIP: Persist the database in a single file named "session.dat"
|
// Persist the database in a single file named "session.dat"
|
||||||
func (m *MemoryDB) Persist() error {
|
func (m *MemoryDB) Persist() error {
|
||||||
running := m.All()
|
running := m.All()
|
||||||
|
|
||||||
@@ -115,17 +87,16 @@ func (m *MemoryDB) Persist() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WIP: Restore a persisted state
|
// Restore a persisted state
|
||||||
func (m *MemoryDB) Restore(logger *slog.Logger) {
|
func (m *MemoryDB) Restore(logger *slog.Logger) {
|
||||||
fd, err := os.Open("session.dat")
|
fd, err := os.Open("session.dat")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session := Session{}
|
var session Session
|
||||||
|
|
||||||
err = gob.NewDecoder(fd).Decode(&session)
|
if err := gob.NewDecoder(fd).Decode(&session); err != nil {
|
||||||
if err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MessageQueue struct {
|
type MessageQueue struct {
|
||||||
producerCh chan *Process
|
producerCh chan *Process
|
||||||
consumerCh chan struct{}
|
consumerCh chan struct{}
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new message queue.
|
// Creates a new message queue.
|
||||||
// By default it will be created with a size equals to nthe number of logical
|
// By default it will be created with a size equals to nthe number of logical
|
||||||
// CPU cores.
|
// CPU cores.
|
||||||
// The queue size can be set via the qs flag.
|
// The queue size can be set via the qs flag.
|
||||||
func NewMessageQueue() *MessageQueue {
|
func NewMessageQueue(l *slog.Logger) *MessageQueue {
|
||||||
size := config.Instance().QueueSize
|
size := config.Instance().QueueSize
|
||||||
|
|
||||||
if size <= 0 {
|
if size <= 0 {
|
||||||
@@ -23,13 +26,21 @@ func NewMessageQueue() *MessageQueue {
|
|||||||
return &MessageQueue{
|
return &MessageQueue{
|
||||||
producerCh: make(chan *Process, size),
|
producerCh: make(chan *Process, size),
|
||||||
consumerCh: make(chan struct{}, size),
|
consumerCh: make(chan struct{}, size),
|
||||||
|
logger: l,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish a message to the queue and set the task to a peding state.
|
// Publish a message to the queue and set the task to a peding state.
|
||||||
func (m *MessageQueue) Publish(p *Process) {
|
func (m *MessageQueue) Publish(p *Process) {
|
||||||
p.SetPending()
|
p.SetPending()
|
||||||
go p.SetMetadata()
|
go func() {
|
||||||
|
if err := p.SetMetadata(); err != nil {
|
||||||
|
m.logger.Error(
|
||||||
|
"failed to retrieve metadata",
|
||||||
|
slog.String("err", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
m.producerCh <- p
|
m.producerCh <- p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,17 +29,15 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m := metadata{}
|
var m metadata
|
||||||
|
|
||||||
err = cmd.Start()
|
if err := cmd.Start(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("decoding metadata", slog.String("url", req.URL))
|
logger.Info("decoding metadata", slog.String("url", req.URL))
|
||||||
|
|
||||||
err = json.NewDecoder(stdout).Decode(&m)
|
if err := json.NewDecoder(stdout).Decode(&m); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +70,10 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
|
|||||||
proc := &Process{
|
proc := &Process{
|
||||||
Url: meta.OriginalURL,
|
Url: meta.OriginalURL,
|
||||||
Progress: DownloadProgress{},
|
Progress: DownloadProgress{},
|
||||||
Output: DownloadOutput{
|
Output: DownloadOutput{Filename: req.Rename},
|
||||||
Filename: req.Rename,
|
Info: meta,
|
||||||
},
|
Params: req.Params,
|
||||||
Info: meta,
|
Logger: logger,
|
||||||
Params: req.Params,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proc.Info.URL = meta.OriginalURL
|
proc.Info.URL = meta.OriginalURL
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
@@ -15,7 +19,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/marcopeocchi/fazzoletti/slices"
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
|
||||||
@@ -35,13 +38,6 @@ const (
|
|||||||
StatusErrored
|
StatusErrored
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProgressTemplate struct {
|
|
||||||
Percentage string `json:"percentage"`
|
|
||||||
Speed float32 `json:"speed"`
|
|
||||||
Size string `json:"size"`
|
|
||||||
Eta float32 `json:"eta"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process descriptor
|
// Process descriptor
|
||||||
type Process struct {
|
type Process struct {
|
||||||
Id string
|
Id string
|
||||||
@@ -54,11 +50,6 @@ type Process struct {
|
|||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadOutput struct {
|
|
||||||
Path string
|
|
||||||
Filename string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starts spawns/forks a new yt-dlp process and parse its stdout.
|
// Starts spawns/forks a new yt-dlp process and parse its stdout.
|
||||||
// The process is spawned to outputting a custom progress text that
|
// The process is spawned to outputting a custom progress text that
|
||||||
// Resembles a JSON Object in order to Unmarshal it later.
|
// Resembles a JSON Object in order to Unmarshal it later.
|
||||||
@@ -67,13 +58,13 @@ type DownloadOutput struct {
|
|||||||
func (p *Process) Start() {
|
func (p *Process) Start() {
|
||||||
// escape bash variable escaping and command piping, you'll never know
|
// escape bash variable escaping and command piping, you'll never know
|
||||||
// what they might come with...
|
// what they might come with...
|
||||||
p.Params = slices.Filter(p.Params, func(e string) bool {
|
p.Params = slices.DeleteFunc(p.Params, func(e string) bool {
|
||||||
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
|
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
|
||||||
return !match
|
return match
|
||||||
})
|
})
|
||||||
|
|
||||||
p.Params = slices.Filter(p.Params, func(e string) bool {
|
p.Params = slices.DeleteFunc(p.Params, func(e string) bool {
|
||||||
return e != ""
|
return e == ""
|
||||||
})
|
})
|
||||||
|
|
||||||
out := DownloadOutput{
|
out := DownloadOutput{
|
||||||
@@ -91,6 +82,8 @@ func (p *Process) Start() {
|
|||||||
|
|
||||||
buildFilename(&p.Output)
|
buildFilename(&p.Output)
|
||||||
|
|
||||||
|
go p.GetFileName(&out)
|
||||||
|
|
||||||
params := []string{
|
params := []string{
|
||||||
strings.Split(p.Url, "?list")[0], //no playlist
|
strings.Split(p.Url, "?list")[0], //no playlist
|
||||||
"--newline",
|
"--newline",
|
||||||
@@ -101,7 +94,7 @@ func (p *Process) Start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if user asked to manually override the output path...
|
// if user asked to manually override the output path...
|
||||||
if !(slices.Includes(params, "-P") || slices.Includes(params, "--paths")) {
|
if !(slices.Contains(params, "-P") || slices.Contains(params, "--paths")) {
|
||||||
params = append(params, "-o")
|
params = append(params, "-o")
|
||||||
params = append(params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
|
params = append(params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
|
||||||
}
|
}
|
||||||
@@ -120,7 +113,6 @@ func (p *Process) Start() {
|
|||||||
)
|
)
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
scan := bufio.NewScanner(r)
|
|
||||||
|
|
||||||
err = cmd.Start()
|
err = cmd.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,6 +134,8 @@ func (p *Process) Start() {
|
|||||||
// spawn a goroutine that does the dirty job of parsing the stdout
|
// spawn a goroutine that does the dirty job of parsing the stdout
|
||||||
// filling the channel with as many stdout line as yt-dlp produces (producer)
|
// filling the channel with as many stdout line as yt-dlp produces (producer)
|
||||||
go func() {
|
go func() {
|
||||||
|
scan := bufio.NewScanner(r)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
r.Close()
|
r.Close()
|
||||||
p.Complete()
|
p.Complete()
|
||||||
@@ -158,21 +152,24 @@ func (p *Process) Start() {
|
|||||||
// Slows down the unmarshal operation to every 500ms
|
// Slows down the unmarshal operation to every 500ms
|
||||||
go func() {
|
go func() {
|
||||||
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
|
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
|
||||||
stdout := ProgressTemplate{}
|
var progress ProgressTemplate
|
||||||
err := json.Unmarshal(event, &stdout)
|
|
||||||
if err == nil {
|
if err := json.Unmarshal(event, &progress); err != nil {
|
||||||
p.Progress = DownloadProgress{
|
return
|
||||||
Status: StatusDownloading,
|
|
||||||
Percentage: stdout.Percentage,
|
|
||||||
Speed: stdout.Speed,
|
|
||||||
ETA: stdout.Eta,
|
|
||||||
}
|
|
||||||
p.Logger.Info("progress",
|
|
||||||
slog.String("id", p.getShortId()),
|
|
||||||
slog.String("url", p.Url),
|
|
||||||
slog.String("percentege", stdout.Percentage),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.Progress = DownloadProgress{
|
||||||
|
Status: StatusDownloading,
|
||||||
|
Percentage: progress.Percentage,
|
||||||
|
Speed: progress.Speed,
|
||||||
|
ETA: progress.Eta,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Logger.Info("progress",
|
||||||
|
slog.String("id", p.getShortId()),
|
||||||
|
slog.String("url", p.Url),
|
||||||
|
slog.String("percentage", progress.Percentage),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -220,9 +217,13 @@ func (p *Process) Kill() error {
|
|||||||
// Returns the available format for this URL
|
// Returns the available format for this URL
|
||||||
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
||||||
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
|
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
|
||||||
stdout, err := cmd.Output()
|
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
p.Logger.Error(
|
||||||
|
"failed to retrieve metadata",
|
||||||
|
slog.String("err", err.Error()),
|
||||||
|
)
|
||||||
return DownloadFormats{}, err
|
return DownloadFormats{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,10 +237,6 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
|||||||
|
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return DownloadFormats{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println(
|
log.Println(
|
||||||
cli.BgRed, "Metadata", cli.Reset,
|
cli.BgRed, "Metadata", cli.Reset,
|
||||||
cli.BgBlue, "Formats", cli.Reset,
|
cli.BgBlue, "Formats", cli.Reset,
|
||||||
@@ -273,7 +270,31 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Process) GetFileName(o *DownloadOutput) error {
|
||||||
|
cmd := exec.Command(
|
||||||
|
config.Instance().DownloaderPath,
|
||||||
|
"--print", "filename",
|
||||||
|
"-o", fmt.Sprintf("%s/%s", o.Path, o.Filename),
|
||||||
|
p.Url,
|
||||||
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Output.SavedFilePath = strings.Trim(string(out), "\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Process) SetPending() {
|
func (p *Process) SetPending() {
|
||||||
|
// Since video's title isn't available yet, fill in with the URL.
|
||||||
|
p.Info = DownloadInfo{
|
||||||
|
URL: p.Url,
|
||||||
|
Title: p.Url,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
p.Progress.Status = StatusPending
|
p.Progress.Status = StatusPending
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +304,17 @@ func (p *Process) SetMetadata() error {
|
|||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.Logger.Error("failed retrieving info",
|
p.Logger.Error("failed to connect to stdout",
|
||||||
|
slog.String("id", p.getShortId()),
|
||||||
|
slog.String("url", p.Url),
|
||||||
|
slog.String("err", err.Error()),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
p.Logger.Error("failed to connect to stderr",
|
||||||
slog.String("id", p.getShortId()),
|
slog.String("id", p.getShortId()),
|
||||||
slog.String("url", p.Url),
|
slog.String("url", p.Url),
|
||||||
slog.String("err", err.Error()),
|
slog.String("err", err.Error()),
|
||||||
@@ -296,27 +327,33 @@ func (p *Process) SetMetadata() error {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = cmd.Start()
|
if err := cmd.Start(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bufferedStderr bytes.Buffer
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
io.Copy(&bufferedStderr, stderr)
|
||||||
|
}()
|
||||||
|
|
||||||
p.Logger.Info("retrieving metadata",
|
p.Logger.Info("retrieving metadata",
|
||||||
slog.String("id", p.getShortId()),
|
slog.String("id", p.getShortId()),
|
||||||
slog.String("url", p.Url),
|
slog.String("url", p.Url),
|
||||||
)
|
)
|
||||||
|
|
||||||
err = json.NewDecoder(stdout).Decode(&info)
|
if err := json.NewDecoder(stdout).Decode(&info); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
p.Info = info
|
p.Info = info
|
||||||
p.Progress.Status = StatusPending
|
p.Progress.Status = StatusPending
|
||||||
|
|
||||||
err = cmd.Wait()
|
if err := cmd.Wait(); err != nil {
|
||||||
|
return errors.New(bufferedStderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Process) getShortId() string {
|
func (p *Process) getShortId() string {
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
|
type Node[T any] struct {
|
||||||
|
Value T
|
||||||
|
}
|
||||||
|
|
||||||
type Stack[T any] struct {
|
type Stack[T any] struct {
|
||||||
Elements []*T
|
Nodes []*Node[T]
|
||||||
count int
|
count int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStack[T any]() *Stack[T] {
|
func NewStack[T any]() *Stack[T] {
|
||||||
return &Stack[T]{
|
return &Stack[T]{
|
||||||
Elements: make([]*T, 10),
|
Nodes: make([]*Node[T], 10),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stack[T]) Push(val T) {
|
func (s *Stack[T]) Push(val T) {
|
||||||
if s.count >= len(s.Elements) {
|
if s.count >= len(s.Nodes) {
|
||||||
Elements := make([]*T, len(s.Elements)*2)
|
Nodes := make([]*Node[T], len(s.Nodes)*2)
|
||||||
copy(Elements, s.Elements)
|
copy(Nodes, s.Nodes)
|
||||||
s.Elements = Elements
|
s.Nodes = Nodes
|
||||||
}
|
}
|
||||||
s.Elements[s.count] = &val
|
s.Nodes[s.count] = &Node[T]{Value: val}
|
||||||
s.count++
|
s.count++
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stack[T]) Pop() *T {
|
func (s *Stack[T]) Pop() *Node[T] {
|
||||||
if s.count == 0 {
|
if s.count == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
Element := s.Elements[s.count-1]
|
node := s.Nodes[s.count-1]
|
||||||
s.count--
|
s.count--
|
||||||
return Element
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stack[T]) IsEmpty() bool {
|
func (s *Stack[T]) IsEmpty() bool {
|
||||||
|
|||||||
89
server/logging/file_logger.go
Normal file
89
server/logging/file_logger.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
File base logger with log-rotate capabilities.
|
||||||
|
The rotate process must be initiated from an external goroutine.
|
||||||
|
|
||||||
|
After rotation the previous logs file are compressed with gzip algorithm.
|
||||||
|
|
||||||
|
The rotated log follows this naming: [filename].UTC time.gz
|
||||||
|
*/
|
||||||
|
|
||||||
|
// implements io.Writer interface
|
||||||
|
type LogRotateWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
fd *os.File
|
||||||
|
filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRotableLogger(filename string) (*LogRotateWriter, error) {
|
||||||
|
fd, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w := &LogRotateWriter{filename: filename, fd: fd}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *LogRotateWriter) Write(b []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.fd.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *LogRotateWriter) Rotate() error {
|
||||||
|
var err error
|
||||||
|
w.mu.Lock()
|
||||||
|
|
||||||
|
gzFile, err := os.Create(w.filename + "." + time.Now().Format(time.RFC3339) + ".gz")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(w.fd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
w.mu.Unlock()
|
||||||
|
w.gzipLog(gzFile, &data)
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = os.Stat(w.filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.fd != nil {
|
||||||
|
err = w.fd.Close()
|
||||||
|
w.fd = nil
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(w.filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.fd, err = os.Create(w.filename)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *LogRotateWriter) gzipLog(wr io.Writer, data *[]byte) error {
|
||||||
|
if _, err := gzip.NewWriter(wr).Write(*data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,10 +6,21 @@ import (
|
|||||||
"github.com/reactivex/rxgo/v2"
|
"github.com/reactivex/rxgo/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Logger implementation using the observable pattern.
|
||||||
|
Implements io.Writer interface.
|
||||||
|
|
||||||
|
The observable is an event source which drops everythigng unless there's
|
||||||
|
a subscriber connected.
|
||||||
|
|
||||||
|
The observer implementatios are a http ServerSentEvents handler and a
|
||||||
|
websocket one in handler.go
|
||||||
|
*/
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logsChan = make(chan rxgo.Item, 100)
|
logsChan = make(chan rxgo.Item, 100)
|
||||||
logsObservable = rxgo.
|
logsObservable = rxgo.
|
||||||
FromChannel(logsChan, rxgo.WithBackPressureStrategy(rxgo.Drop)).
|
FromEventSource(logsChan, rxgo.WithBackPressureStrategy(rxgo.Drop)).
|
||||||
BufferWithTime(rxgo.WithDuration(time.Millisecond * 500))
|
BufferWithTime(rxgo.WithDuration(time.Millisecond * 500))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func validateToken(tokenValue string) error {
|
func validateToken(tokenValue string) error {
|
||||||
if tokenValue == "" {
|
token, err := jwt.Parse(tokenValue, func(t *jwt.Token) (interface{}, error) {
|
||||||
return errors.New("invalid token")
|
|
||||||
}
|
|
||||||
|
|
||||||
token, _ := jwt.Parse(tokenValue, func(t *jwt.Token) (interface{}, error) {
|
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
}
|
}
|
||||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||||
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
|
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -39,6 +37,9 @@ func validateToken(tokenValue string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication does NOT use http-Only cookies since there's not risk for XSS
|
||||||
|
// By exposing the server through https it's completely safe to use httpheaders
|
||||||
|
|
||||||
func Authenticated(next http.Handler) http.Handler {
|
func Authenticated(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
token := r.Header.Get("X-Authentication")
|
token := r.Header.Get("X-Authentication")
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package middlewares
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"mime"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SpaHandler struct {
|
|
||||||
Entrypoint string
|
|
||||||
Filesystem fs.FS
|
|
||||||
routes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSpaHandler(index string, fs fs.FS) *SpaHandler {
|
|
||||||
return &SpaHandler{
|
|
||||||
Entrypoint: index,
|
|
||||||
Filesystem: fs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SpaHandler) AddClientRoute(route string) *SpaHandler {
|
|
||||||
s.routes = append(s.routes, route)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handler for serving a compiled react frontend
|
|
||||||
// each client-side routes must be provided
|
|
||||||
func (s *SpaHandler) Handler() http.HandlerFunc {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(
|
|
||||||
w,
|
|
||||||
http.StatusText(http.StatusMethodNotAllowed),
|
|
||||||
http.StatusMethodNotAllowed,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Clean(r.URL.Path)
|
|
||||||
|
|
||||||
// basically all frontend routes are needed :/
|
|
||||||
hasRoute := false
|
|
||||||
for _, route := range s.routes {
|
|
||||||
hasRoute = strings.HasPrefix(path, route)
|
|
||||||
if hasRoute {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if path == "/" || hasRoute {
|
|
||||||
path = s.Entrypoint
|
|
||||||
}
|
|
||||||
|
|
||||||
path = strings.TrimPrefix(path, "/")
|
|
||||||
|
|
||||||
file, err := s.Filesystem.Open(path)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(
|
|
||||||
w,
|
|
||||||
http.StatusText(http.StatusInternalServerError),
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := mime.TypeByExtension(filepath.Ext(path))
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
|
||||||
|
|
||||||
if strings.HasPrefix(path, "assets/") {
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=2592000")
|
|
||||||
}
|
|
||||||
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err == nil && stat.Size() > 0 {
|
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
io.Copy(w, file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -26,12 +26,10 @@ func ApplyRouter(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue)
|
|||||||
}
|
}
|
||||||
r.Post("/exec", h.Exec())
|
r.Post("/exec", h.Exec())
|
||||||
r.Get("/running", h.Running())
|
r.Get("/running", h.Running())
|
||||||
|
r.Get("/version", h.GetVersion())
|
||||||
r.Post("/cookies", h.SetCookies())
|
r.Post("/cookies", h.SetCookies())
|
||||||
r.Post("/template", h.AddTemplate())
|
r.Post("/template", h.AddTemplate())
|
||||||
r.Get("/template/all", h.GetTemplates())
|
r.Get("/template/all", h.GetTemplates())
|
||||||
r.Delete("/template/{id}", h.DeleteTemplate())
|
r.Delete("/template/{id}", h.DeleteTemplate())
|
||||||
|
|
||||||
r.Get("/tree", h.DirectoryTree())
|
|
||||||
r.Get("/d/{id}", h.DownloadFile())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package rest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
@@ -15,6 +12,10 @@ type Handler struct {
|
|||||||
service *Service
|
service *Service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
REST version of the JSON-RPC interface
|
||||||
|
*/
|
||||||
|
|
||||||
func (h *Handler) Exec() http.HandlerFunc {
|
func (h *Handler) Exec() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
@@ -158,54 +159,20 @@ func (h *Handler) DeleteTemplate() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) DirectoryTree() http.HandlerFunc {
|
func (h *Handler) GetVersion() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
tree, err := h.service.DirectoryTree(r.Context())
|
version, err := h.service.GetVersion(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.NewEncoder(w).Encode(tree)
|
if err := json.NewEncoder(w).Encode(version); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) DownloadFile() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
defer r.Body.Close()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
path, err := h.service.DownloadFile(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Add(
|
|
||||||
"Content-Disposition",
|
|
||||||
"inline; filename="+filepath.Base(*path),
|
|
||||||
)
|
|
||||||
w.Header().Set(
|
|
||||||
"Content-Type",
|
|
||||||
"application/octet-stream",
|
|
||||||
)
|
|
||||||
|
|
||||||
fd, err := os.Open(*path)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
io.Copy(w, fd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -120,15 +122,22 @@ func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) DirectoryTree(ctx context.Context) (*internal.Stack[sys.FSNode], error) {
|
func (s *Service) GetVersion(ctx context.Context) (string, error) {
|
||||||
return sys.DirectoryTree()
|
ch := make(chan string, 1)
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) DownloadFile(ctx context.Context, id string) (*string, error) {
|
c, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||||
p, err := s.mdb.Get(id)
|
defer cancel()
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
cmd := exec.CommandContext(c, config.Instance().DownloaderPath, "--version")
|
||||||
|
go func() {
|
||||||
|
stdout, _ := cmd.Output()
|
||||||
|
ch <- string(stdout)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.Done():
|
||||||
|
return "", errors.New("requesting yt-dlp version took too long")
|
||||||
|
case res := <-ch:
|
||||||
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &p.Output.Path, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ var upgrader = websocket.Upgrader{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebSockets JSON-RPC handler
|
||||||
func WebSocket(w http.ResponseWriter, r *http.Request) {
|
func WebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
c, err := upgrader.Upgrade(w, r, nil)
|
c, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -47,6 +48,7 @@ func WebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP-POST JSON-RPC handler
|
||||||
func Post(w http.ResponseWriter, r *http.Request) {
|
func Post(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return a flattned tree of the download directory
|
// Return a flattned tree of the download directory
|
||||||
func (s *Service) DirectoryTree(args NoArgs, tree *internal.Stack[sys.FSNode]) error {
|
func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
|
||||||
dfsTree, err := sys.DirectoryTree()
|
dfsTree, err := sys.DirectoryTree()
|
||||||
if dfsTree != nil {
|
if dfsTree != nil {
|
||||||
*tree = *dfsTree
|
*tree = *dfsTree
|
||||||
@@ -154,6 +154,7 @@ func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
|
|||||||
err := updater.UpdateExecutable()
|
err := updater.UpdateExecutable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
*updated = true
|
*updated = true
|
||||||
|
s.logger.Info("Succesfully updated yt-dlp")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
*updated = false
|
*updated = false
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import (
|
|||||||
"net/rpc/jsonrpc"
|
"net/rpc/jsonrpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Wrapper for HTTP RPC request that implements io.Reader interface
|
// Wrapper for jsonrpc.ServeConn that simplifies its usage
|
||||||
type rpcRequest struct {
|
type rpcRequest struct {
|
||||||
r io.Reader
|
r io.Reader
|
||||||
rw io.ReadWriter
|
rw io.ReadWriter
|
||||||
done chan bool
|
done chan bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Takes a reader that can be an *http.Request or anthing that implements
|
||||||
|
// io.ReadWriter interface.
|
||||||
|
// Call() will perform the jsonRPC call and write or read from the ReadWriter
|
||||||
func newRequest(r io.Reader) *rpcRequest {
|
func newRequest(r io.Reader) *rpcRequest {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
done := make(chan bool)
|
done := make(chan bool)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import "time"
|
|||||||
//
|
//
|
||||||
// Debounce emits the most recently emitted value from the source
|
// Debounce emits the most recently emitted value from the source
|
||||||
// withing the timespan set by the span time.Duration
|
// withing the timespan set by the span time.Duration
|
||||||
|
//
|
||||||
|
// Soon it will be deprecated since it doesn't add anything useful.
|
||||||
|
// (It lowers the CPU usage by a negligible margin)
|
||||||
func Sample(span time.Duration, source chan []byte, done chan struct{}, fn func(e []byte)) {
|
func Sample(span time.Duration, source chan []byte, done chan struct{}, fn func(e []byte)) {
|
||||||
var (
|
var (
|
||||||
item []byte
|
item []byte
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/rpc"
|
"net/rpc"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,6 +30,15 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type RunConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
App fs.FS
|
||||||
|
DBPath string
|
||||||
|
LogFile string
|
||||||
|
FileLogging bool
|
||||||
|
}
|
||||||
|
|
||||||
type serverConfig struct {
|
type serverConfig struct {
|
||||||
frontend fs.FS
|
frontend fs.FS
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
@@ -38,19 +49,37 @@ type serverConfig struct {
|
|||||||
mq *internal.MessageQueue
|
mq *internal.MessageQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
|
func RunBlocking(cfg *RunConfig) {
|
||||||
var mdb internal.MemoryDB
|
var mdb internal.MemoryDB
|
||||||
|
|
||||||
|
logWriters := []io.Writer{
|
||||||
|
os.Stdout,
|
||||||
|
logging.NewObservableLogger(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.FileLogging {
|
||||||
|
logger, err := logging.NewRotableLogger(cfg.LogFile)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Hour * 24)
|
||||||
|
logger.Rotate()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
logWriters = append(logWriters, logger)
|
||||||
|
}
|
||||||
|
|
||||||
logger := slog.New(
|
logger := slog.New(
|
||||||
slog.NewTextHandler(
|
slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}),
|
||||||
io.MultiWriter(os.Stdout, logging.NewObservableLogger()),
|
|
||||||
nil,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
mdb.Restore(logger)
|
mdb.Restore(logger)
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", dbPath)
|
db, err := sql.Open("sqlite", cfg.DBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to open database", slog.String("err", err.Error()))
|
logger.Error("failed to open database", slog.String("err", err.Error()))
|
||||||
}
|
}
|
||||||
@@ -60,14 +89,14 @@ func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
|
|||||||
logger.Error("failed to init database", slog.String("err", err.Error()))
|
logger.Error("failed to init database", slog.String("err", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
mq := internal.NewMessageQueue()
|
mq := internal.NewMessageQueue(logger)
|
||||||
go mq.Subscriber()
|
go mq.Subscriber()
|
||||||
|
|
||||||
srv := newServer(serverConfig{
|
srv := newServer(serverConfig{
|
||||||
frontend: frontend,
|
frontend: cfg.App,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
host: host,
|
host: cfg.Host,
|
||||||
port: port,
|
port: cfg.Port,
|
||||||
mdb: &mdb,
|
mdb: &mdb,
|
||||||
mq: mq,
|
mq: mq,
|
||||||
db: db,
|
db: db,
|
||||||
@@ -76,9 +105,25 @@ func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
|
|||||||
go gracefulShutdown(srv, &mdb)
|
go gracefulShutdown(srv, &mdb)
|
||||||
go autoPersist(time.Minute*5, &mdb, logger)
|
go autoPersist(time.Minute*5, &mdb, logger)
|
||||||
|
|
||||||
logger.Info("yt-dlp-webui started", slog.Int("port", port))
|
var (
|
||||||
|
network = "tcp"
|
||||||
|
address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
)
|
||||||
|
|
||||||
if err := srv.ListenAndServe(); err != nil {
|
if strings.HasPrefix(cfg.Host, "/") {
|
||||||
|
network = "unix"
|
||||||
|
address = cfg.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen(network, address)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to listen", slog.String("err", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("yt-dlp-webui started", slog.String("address", address))
|
||||||
|
|
||||||
|
if err := srv.Serve(listener); err != nil {
|
||||||
logger.Warn("http server stopped", slog.String("err", err.Error()))
|
logger.Warn("http server stopped", slog.String("err", err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +163,7 @@ func newServer(c serverConfig) *http.Server {
|
|||||||
r.Post("/delete", handlers.DeleteFile)
|
r.Post("/delete", handlers.DeleteFile)
|
||||||
r.Get("/d/{id}", handlers.DownloadFile)
|
r.Get("/d/{id}", handlers.DownloadFile)
|
||||||
r.Get("/v/{id}", handlers.SendFile)
|
r.Get("/v/{id}", handlers.SendFile)
|
||||||
|
r.Get("/bulk", handlers.BulkDownload(c.mdb))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Authentication routes
|
// Authentication routes
|
||||||
@@ -135,10 +181,7 @@ func newServer(c serverConfig) *http.Server {
|
|||||||
// Logging
|
// Logging
|
||||||
r.Route("/log", logging.ApplyRouter())
|
r.Route("/log", logging.ApplyRouter())
|
||||||
|
|
||||||
return &http.Server{
|
return &http.Server{Handler: r}
|
||||||
Addr: fmt.Sprintf("%s:%d", c.host, c.port),
|
|
||||||
Handler: r,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
|
func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
|
||||||
|
|||||||
@@ -18,35 +18,43 @@ func FreeSpace() (uint64, error) {
|
|||||||
return (stat.Bavail * uint64(stat.Bsize)), nil
|
return (stat.Bavail * uint64(stat.Bsize)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type FSNode struct {
|
|
||||||
path string
|
|
||||||
children []FSNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a directory tree started from the specified path using DFS.
|
// Build a directory tree started from the specified path using DFS.
|
||||||
// Then return the flattened tree represented as a list.
|
// Then return the flattened tree represented as a list.
|
||||||
func DirectoryTree() (*internal.Stack[FSNode], error) {
|
func DirectoryTree() (*[]string, error) {
|
||||||
rootPath := config.Instance().DownloadPath
|
type Node struct {
|
||||||
|
path string
|
||||||
|
children []Node
|
||||||
|
}
|
||||||
|
|
||||||
stack := internal.NewStack[FSNode]()
|
var (
|
||||||
|
rootPath = config.Instance().DownloadPath
|
||||||
|
|
||||||
stack.Push(FSNode{path: rootPath})
|
stack = internal.NewStack[Node]()
|
||||||
|
flattened = make([]string, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
stack.Push(Node{path: rootPath})
|
||||||
|
|
||||||
|
flattened = append(flattened, rootPath)
|
||||||
|
|
||||||
for stack.IsNotEmpty() {
|
for stack.IsNotEmpty() {
|
||||||
current := stack.Pop()
|
current := stack.Pop().Value
|
||||||
|
|
||||||
children, err := os.ReadDir(current.path)
|
children, err := os.ReadDir(current.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, entry := range children {
|
for _, entry := range children {
|
||||||
childPath := filepath.Join(current.path, entry.Name())
|
var (
|
||||||
childNode := FSNode{path: childPath}
|
childPath = filepath.Join(current.path, entry.Name())
|
||||||
|
childNode = Node{path: childPath}
|
||||||
|
)
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
current.children = append(current.children, childNode)
|
current.children = append(current.children, childNode)
|
||||||
stack.Push(childNode)
|
stack.Push(childNode)
|
||||||
|
flattened = append(flattened, childNode.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stack, nil
|
return &flattened, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,9 +19,3 @@ func IsValidEntry(d fs.DirEntry) bool {
|
|||||||
!strings.HasSuffix(d.Name(), ".part") &&
|
!strings.HasSuffix(d.Name(), ".part") &&
|
||||||
!strings.HasSuffix(d.Name(), ".ytdl")
|
!strings.HasSuffix(d.Name(), ".ytdl")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ShaSumString(path string) string {
|
|
||||||
h := sha256.New()
|
|
||||||
h.Write([]byte(path))
|
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func LogRotate() (*os.File, error) {
|
|
||||||
logs := findLogs()
|
|
||||||
|
|
||||||
for _, log := range logs {
|
|
||||||
logfd, err := os.Open(log)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
gzWriter, err := os.Create(log + ".gz")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(gzWriter, logfd)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logfile := time.Now().String() + ".log"
|
|
||||||
config.Instance().CurrentLogFile = logfile
|
|
||||||
|
|
||||||
return os.Create(logfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findLogs() []string {
|
|
||||||
var (
|
|
||||||
logfiles []string
|
|
||||||
root = config.Instance().LogPath
|
|
||||||
)
|
|
||||||
|
|
||||||
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if filepath.Ext(d.Name()) == ".log" {
|
|
||||||
logfiles = append(logfiles, path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return logfiles
|
|
||||||
}
|
|
||||||
24
ui/.gitignore
vendored
24
ui/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
3
ui/.vscode/extensions.json
vendored
3
ui/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
|
||||||
}
|
|
||||||
47
ui/README.md
47
ui/README.md
@@ -1,47 +0,0 @@
|
|||||||
# Svelte + TS + Vite
|
|
||||||
|
|
||||||
This template should help get you started developing with Svelte and TypeScript in Vite.
|
|
||||||
|
|
||||||
## Recommended IDE Setup
|
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
|
||||||
|
|
||||||
## Need an official Svelte framework?
|
|
||||||
|
|
||||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
|
||||||
|
|
||||||
## Technical considerations
|
|
||||||
|
|
||||||
**Why use this over SvelteKit?**
|
|
||||||
|
|
||||||
- It brings its own routing solution which might not be preferable for some users.
|
|
||||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
|
||||||
|
|
||||||
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
|
||||||
|
|
||||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
|
||||||
|
|
||||||
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
|
||||||
|
|
||||||
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
|
||||||
|
|
||||||
**Why include `.vscode/extensions.json`?**
|
|
||||||
|
|
||||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
|
||||||
|
|
||||||
**Why enable `allowJs` in the TS template?**
|
|
||||||
|
|
||||||
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
|
|
||||||
|
|
||||||
**Why is HMR not preserving my local component state?**
|
|
||||||
|
|
||||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
|
|
||||||
|
|
||||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// store.ts
|
|
||||||
// An extremely simple external store
|
|
||||||
import { writable } from 'svelte/store'
|
|
||||||
export default writable(0)
|
|
||||||
```
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/svelte.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>yt-dlp WebUI</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ui",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.1",
|
|
||||||
"@tsconfig/svelte": "^5.0.2",
|
|
||||||
"@zerodevx/svelte-toast": "^0.9.5",
|
|
||||||
"autoprefixer": "^10.4.17",
|
|
||||||
"postcss": "^8.4.34",
|
|
||||||
"svelte": "^4.2.8",
|
|
||||||
"svelte-check": "^3.6.2",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"tslib": "^2.6.2",
|
|
||||||
"typescript": "^5.2.2",
|
|
||||||
"vite": "^5.0.8"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fontsource/roboto": "^5.0.8",
|
|
||||||
"fp-ts": "^2.16.2",
|
|
||||||
"lucide-svelte": "^0.323.0",
|
|
||||||
"svelte-spa-router": "^4.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1693
ui/pnpm-lock.yaml
generated
1693
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,25 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { SvelteToast } from '@zerodevx/svelte-toast';
|
|
||||||
import Router from 'svelte-spa-router';
|
|
||||||
import { wrap } from 'svelte-spa-router/wrap';
|
|
||||||
import Footer from './lib/Footer.svelte';
|
|
||||||
import Home from './views/Home.svelte';
|
|
||||||
import Navbar from './lib/Navbar.svelte';
|
|
||||||
|
|
||||||
const routes = {
|
|
||||||
'/': Home,
|
|
||||||
'/settings': wrap({
|
|
||||||
asyncComponent: () => import('./views/SettingsView.svelte'),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main
|
|
||||||
class="bg-neutral-50 dark:bg-neutral-900 h-screen text-neutral-950 dark:text-neutral-50"
|
|
||||||
>
|
|
||||||
<Navbar />
|
|
||||||
<Router {routes} />
|
|
||||||
<Footer />
|
|
||||||
<SvelteToast />
|
|
||||||
<!-- <FloatingAction /> -->
|
|
||||||
</main>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* body {
|
|
||||||
font-family: "Roboto";
|
|
||||||
} */
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let clazz: string = '';
|
|
||||||
export let disabled: boolean = false;
|
|
||||||
export { clazz as class };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class={`px-2.5 py-2 rounded-lg bg-blue-300 hover:bg-blue-400 hover:duration-150 text-sm font-semibold ${
|
|
||||||
disabled && 'bg-neutral-300 hover:bg-neutral-300'
|
|
||||||
} ${clazz}`}
|
|
||||||
{disabled}
|
|
||||||
on:click
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</button>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
export let text: string;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1.5 p-1 bg-blue-200 rounded-lg text-neutral-900"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { toast } from '@zerodevx/svelte-toast';
|
|
||||||
import * as A from 'fp-ts/Array';
|
|
||||||
import * as E from 'fp-ts/Either';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { pipe } from 'fp-ts/lib/function';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { ffetch } from './ffetch';
|
|
||||||
import { cookiesTemplate, serverApiEndpoint } from './store';
|
|
||||||
import { debounce } from './utils';
|
|
||||||
|
|
||||||
const flag = '--cookies=cookies.txt';
|
|
||||||
|
|
||||||
let cookies = localStorage.getItem('cookies') ?? '';
|
|
||||||
|
|
||||||
const validateCookie = (cookie: string) =>
|
|
||||||
pipe(
|
|
||||||
cookie,
|
|
||||||
(cookie) => cookie.replace(/\s\s+/g, ' '),
|
|
||||||
(cookie) => cookie.replaceAll('\t', ' '),
|
|
||||||
(cookie) => cookie.split(' '),
|
|
||||||
E.of,
|
|
||||||
E.flatMap(
|
|
||||||
E.fromPredicate(
|
|
||||||
(f) => f.length === 7,
|
|
||||||
() => `missing parts`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
E.flatMap(
|
|
||||||
E.fromPredicate(
|
|
||||||
(f) => f[0].length > 0,
|
|
||||||
() => 'missing domain',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
E.flatMap(
|
|
||||||
E.fromPredicate(
|
|
||||||
(f) => f[1] === 'TRUE' || f[1] === 'FALSE',
|
|
||||||
() => `invalid include subdomains`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
E.flatMap(
|
|
||||||
E.fromPredicate(
|
|
||||||
(f) => f[2].length > 0,
|
|
||||||
() => 'invalid path',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
E.flatMap(
|
|
||||||
E.fromPredicate(
|
|
||||||
(f) => f[3] === 'TRUE' || f[3] === 'FALSE',
|
|
||||||
() => 'invalid secure flag',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
E.flatMap(
|
|
||||||
E.fromPredicate(
|
|
||||||
(f) => isFinite(Number(f[4])),
|
|
||||||
() => 'invalid expiration',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
E.flatMap(
|
|
||||||
E.fromPredicate(
|
|
||||||
(f) => f[5].length > 0,
|
|
||||||
() => 'invalid name',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
E.flatMap(
|
|
||||||
E.fromPredicate(
|
|
||||||
(f) => f[6].length > 0,
|
|
||||||
() => 'invalid value',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const validateNetscapeCookies = (cookies: string) =>
|
|
||||||
pipe(
|
|
||||||
cookies,
|
|
||||||
(cookies) => cookies.split('\n'),
|
|
||||||
(cookies) => cookies.filter((f) => !f.startsWith('\n')), // empty lines
|
|
||||||
(cookies) => cookies.filter((f) => !f.startsWith('# ')), // comments
|
|
||||||
(cookies) => cookies.filter(Boolean), // empty lines
|
|
||||||
A.map(validateCookie),
|
|
||||||
A.mapWithIndex((i, either) =>
|
|
||||||
pipe(
|
|
||||||
either,
|
|
||||||
E.matchW(
|
|
||||||
(l) => toast.push(`Error in line ${i + 1}: ${l}`),
|
|
||||||
() => E.isRight(either),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
A.filter(Boolean),
|
|
||||||
A.match(
|
|
||||||
() => false,
|
|
||||||
(c) => {
|
|
||||||
toast.push(`Valid ${c.length} Netscape cookies`);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitCookies = (cookies: string) =>
|
|
||||||
ffetch(`${get(serverApiEndpoint)}/api/v1/cookies`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
cookies,
|
|
||||||
}),
|
|
||||||
})();
|
|
||||||
|
|
||||||
const execute = (cookies: KeyboardEvent) =>
|
|
||||||
pipe(
|
|
||||||
cookies.target as HTMLTextAreaElement,
|
|
||||||
(cookies) => cookies.value,
|
|
||||||
O.fromPredicate(validateNetscapeCookies),
|
|
||||||
O.match(
|
|
||||||
() => cookiesTemplate.set(''),
|
|
||||||
async (cookies) => {
|
|
||||||
pipe(
|
|
||||||
await submitCookies(cookies),
|
|
||||||
E.match(
|
|
||||||
(l) => toast.push(l),
|
|
||||||
() => {
|
|
||||||
toast.push(`Saved Netscape cookies`);
|
|
||||||
cookiesTemplate.set(flag);
|
|
||||||
localStorage.setItem('cookies', cookies);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
cols="80"
|
|
||||||
rows="8"
|
|
||||||
value={cookies}
|
|
||||||
on:keyup={debounce(execute, 500)}
|
|
||||||
/>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import Button from './Button.svelte';
|
|
||||||
import Chip from './Chip.svelte';
|
|
||||||
import { rpcClient, serverApiEndpoint } from './store';
|
|
||||||
import type { RPCResult } from './types';
|
|
||||||
import { formatSpeedMiB, roundMiB } from './utils';
|
|
||||||
|
|
||||||
export let download: RPCResult;
|
|
||||||
|
|
||||||
const remove = (id: string) => get(rpcClient).kill(id);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex gap-4
|
|
||||||
bg-neutral-100 dark:bg-neutral-800
|
|
||||||
p-2 md:p-4
|
|
||||||
rounded-lg shadow-lg
|
|
||||||
border dark:border-neutral-700"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-full hidden sm:block w-96 bg-cover bg-center rounded"
|
|
||||||
style="background-image: url({download.info.thumbnail})"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col justify-between gap-2 w-full">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-bold text-lg">{download.info.title}</h2>
|
|
||||||
<p
|
|
||||||
class="font-mono text-sm mt-2 p-1 break-all bg-neutral-200 dark:bg-neutral-700 rounded"
|
|
||||||
>
|
|
||||||
{download.info.url}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col justify-end gap-2 select-none flex-wrap">
|
|
||||||
<div class="hidden sm:flex items-center gap-2 text-sm">
|
|
||||||
{#if download.info.vcodec}
|
|
||||||
<Chip text={download.info.vcodec} />
|
|
||||||
{/if}
|
|
||||||
{#if download.info.acodec}
|
|
||||||
<Chip text={download.info.acodec} />
|
|
||||||
{/if}
|
|
||||||
{#if download.info.ext}
|
|
||||||
<Chip text={download.info.ext} />
|
|
||||||
{/if}
|
|
||||||
{#if download.info.resolution}
|
|
||||||
<Chip text={download.info.resolution} />
|
|
||||||
{/if}
|
|
||||||
{#if download.info.filesize_approx}
|
|
||||||
<Chip text={roundMiB(download.info.filesize_approx)} />
|
|
||||||
{/if}
|
|
||||||
<!-- {#if download.progress.process_status}
|
|
||||||
<Chip text={mapProcessStatus(download.progress.process_status)} />
|
|
||||||
{/if} -->
|
|
||||||
{#if download.progress.speed}
|
|
||||||
<Chip text={formatSpeedMiB(download.progress.speed)} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button class="w-14" on:click={() => remove(download.id)}>Stop</Button>
|
|
||||||
{#if download.progress.process_status === 2}
|
|
||||||
<Button class="w-18">Download</Button>
|
|
||||||
<!-- <a href={`${$serverApiEndpoint}/api/v1/d/${download.id}`}>d</a> -->
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="w-full mt-4 h-2 rounded-full bg-neutral-200 dark:bg-neutral-700"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={`h-2 rounded-full ${
|
|
||||||
download.progress.process_status === 2
|
|
||||||
? 'bg-green-600'
|
|
||||||
: 'bg-blue-500'
|
|
||||||
}`}
|
|
||||||
style="width: {download.progress.percentage}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Plus } from 'lucide-svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="absolute bottom-10 right-10">
|
|
||||||
<!-- <div class="relative mb-4 flex flex-col justify-center items-center gap-2">
|
|
||||||
<button
|
|
||||||
class="relative flex items-center justify-center bg-blue-500 h-8 w-8 z-10 rounded-2xl shadow-xl text-neutral-100"
|
|
||||||
>
|
|
||||||
<Plus size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="relative flex items-center justify-center bg-blue-500 h-8 w-8 z-10 rounded-2xl shadow-xl text-neutral-100"
|
|
||||||
>
|
|
||||||
<Plus size={18} />
|
|
||||||
</button>
|
|
||||||
</div> -->
|
|
||||||
<button
|
|
||||||
class="relative bg-blue-500 p-5 z-10 rounded-2xl shadow-xl text-neutral-100"
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-svelte';
|
|
||||||
import { cubicOut } from 'svelte/easing';
|
|
||||||
import { tweened } from 'svelte/motion';
|
|
||||||
import NewDownload from './NewDownload.svelte';
|
|
||||||
|
|
||||||
const height = tweened(52, {
|
|
||||||
duration: 300,
|
|
||||||
easing: cubicOut,
|
|
||||||
});
|
|
||||||
|
|
||||||
const minHeight = 52;
|
|
||||||
const maxHeight = window.innerHeight / 1.5;
|
|
||||||
|
|
||||||
let open = false;
|
|
||||||
$: open = $height > minHeight;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<footer
|
|
||||||
class="
|
|
||||||
fixed bottom-0 z-10
|
|
||||||
w-full
|
|
||||||
p-2
|
|
||||||
bg-neutral-100 dark:bg-neutral-800
|
|
||||||
border-t dark:border-t-neutral-700
|
|
||||||
shadow-lg
|
|
||||||
rounded-t-xl"
|
|
||||||
style="min-height: {$height}px;"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="p-1 bg-neutral-200 dark:bg-neutral-700 rounded-lg border dark:border-neutral-700"
|
|
||||||
on:click={() => (open ? height.set(minHeight) : height.set(maxHeight))}
|
|
||||||
>
|
|
||||||
{#if open}
|
|
||||||
<ChevronDown />
|
|
||||||
{:else}
|
|
||||||
<ChevronUp />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div />
|
|
||||||
|
|
||||||
{#if $height > 100}
|
|
||||||
<div class="mt-2">
|
|
||||||
<NewDownload />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</footer>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { DLFormat } from './types';
|
|
||||||
|
|
||||||
let group = '';
|
|
||||||
export let formats: DLFormat[];
|
|
||||||
|
|
||||||
$: console.log(group);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="w-full mt-4">
|
|
||||||
<div class="mx-auto w-full">
|
|
||||||
<fieldset class="grid grid-cols-7 gap-2">
|
|
||||||
{#each formats as format}
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
id="formats"
|
|
||||||
class="absolute opacity-0 w-0 h-0 peer"
|
|
||||||
type="radio"
|
|
||||||
bind:group
|
|
||||||
name="type"
|
|
||||||
value="formats"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="formats"
|
|
||||||
class="
|
|
||||||
[&_p]:text-gray-900 [&_span]:text-gray-500
|
|
||||||
peer-checked:[&_p]:text-white peer-checked:[&_span]:text-blue-100
|
|
||||||
peer-focus:ring-2 peer-focus:ring-white
|
|
||||||
peer-focus:ring-opacity-60 peer-focus:ring-offset-2 peer-focus:ring-offset-blue-300
|
|
||||||
bg-white
|
|
||||||
relative flex
|
|
||||||
cursor-pointer
|
|
||||||
rounded-lg px-5 py-4
|
|
||||||
shadow-md
|
|
||||||
focus:outline-none
|
|
||||||
peer-checked:bg-blue-700/75
|
|
||||||
peer-checked:text-white"
|
|
||||||
>
|
|
||||||
<div class="flex w-full items-center justify-between">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="text-sm">
|
|
||||||
<p class="font-medium" id={format.format_id}>
|
|
||||||
{format.resolution}
|
|
||||||
</p>
|
|
||||||
<span class="inline" id={format.format_id}>
|
|
||||||
<span>{format.vcodec}</span>
|
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span>{format.acodec}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 text-white">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" class="h-6 w-6">
|
|
||||||
<circle cx="12" cy="12" r="12" fill="#fff" opacity="0.2" />
|
|
||||||
<path
|
|
||||||
d="M7 13l3 3 7-7"
|
|
||||||
stroke="#fff"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Spinner from './Spinner.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="top-0 left-0 absolute w-full h-full bg-neutral-950/20 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
ArrowDownUp,
|
|
||||||
Github,
|
|
||||||
HardDrive,
|
|
||||||
Network,
|
|
||||||
Settings,
|
|
||||||
} from 'lucide-svelte';
|
|
||||||
import { downloads, rpcClient, serverApiEndpoint } from './store';
|
|
||||||
import { formatGiB, formatSpeedMiB } from './utils';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { pipe } from 'fp-ts/lib/function';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { link } from 'svelte-spa-router';
|
|
||||||
|
|
||||||
let downloadSpeed = 0;
|
|
||||||
|
|
||||||
const unsubscribe = downloads.subscribe((downloads) =>
|
|
||||||
pipe(
|
|
||||||
downloads,
|
|
||||||
O.matchW(
|
|
||||||
() => (downloadSpeed = 0),
|
|
||||||
(d) =>
|
|
||||||
(downloadSpeed = d
|
|
||||||
.map((d) => d.progress.speed)
|
|
||||||
.reduce((a, b) => a + b)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
onDestroy(unsubscribe);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav
|
|
||||||
class="
|
|
||||||
p-4
|
|
||||||
flex justify-between items-center
|
|
||||||
bg-neutral-100 dark:bg-neutral-800
|
|
||||||
rounded-b-xl
|
|
||||||
border-b dark:border-b-neutral-700
|
|
||||||
shadow-lg
|
|
||||||
select-none"
|
|
||||||
>
|
|
||||||
<a use:link={'/'} href="/" class="font-semibold text-lg">yt-dlp WebUI</a>
|
|
||||||
|
|
||||||
<div />
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<div
|
|
||||||
class="hidden sm:flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
|
|
||||||
>
|
|
||||||
<ArrowDownUp size={18} />
|
|
||||||
<div>
|
|
||||||
{formatSpeedMiB(downloadSpeed)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
|
|
||||||
>
|
|
||||||
<HardDrive size={18} />
|
|
||||||
<div>
|
|
||||||
{#await $rpcClient.freeSpace()}
|
|
||||||
Loading...
|
|
||||||
{:then freeSpace}
|
|
||||||
{formatGiB(freeSpace.result)}
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
|
|
||||||
>
|
|
||||||
<Network size={18} />
|
|
||||||
<div>
|
|
||||||
{$serverApiEndpoint.split('//')[1]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://github.com/marcopeocchi/yt-dlp-web-ui"
|
|
||||||
class="flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
|
|
||||||
>
|
|
||||||
<Github size={18} />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
use:link={'/settings'}
|
|
||||||
href="/settings"
|
|
||||||
class="flex items-center gap-1.5 p-1 text-neutral-900 bg-blue-200 rounded-lg"
|
|
||||||
>
|
|
||||||
<Settings size={18} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import Button from './Button.svelte';
|
|
||||||
import TextField from './TextField.svelte';
|
|
||||||
import { downloadTemplates, rpcClient } from './store';
|
|
||||||
import Select from './Select.svelte';
|
|
||||||
import type { DLMetadata } from './types';
|
|
||||||
import FormatsList from './FormatsList.svelte';
|
|
||||||
|
|
||||||
let url: string = '';
|
|
||||||
let args: string = '';
|
|
||||||
let metadata: DLMetadata;
|
|
||||||
|
|
||||||
const download = () =>
|
|
||||||
get(rpcClient).download({
|
|
||||||
url,
|
|
||||||
args,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getFormats = () =>
|
|
||||||
get(rpcClient)
|
|
||||||
.formats(url)
|
|
||||||
?.then((f) => (metadata = f.result));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="w-full px-8">
|
|
||||||
<div class="my-4 font-semibold text-xl">New download</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 w-full mb-2">
|
|
||||||
<TextField placeholder="https://..." label="URL" bind:value={url} />
|
|
||||||
<TextField
|
|
||||||
placeholder="arguments separated by space"
|
|
||||||
label="yt-dlp arguments"
|
|
||||||
bind:value={args}
|
|
||||||
/>
|
|
||||||
<Select bind:value={args}>
|
|
||||||
<option selected disabled value=""> Select download template </option>
|
|
||||||
{#each $downloadTemplates as template}
|
|
||||||
<option id={template.id} value={template.content}>
|
|
||||||
{template.name}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button class="mt-2" on:click={download}>Download</Button>
|
|
||||||
<Button class="mt-2" on:click={getFormats}>Select format</Button>
|
|
||||||
{#if metadata}
|
|
||||||
<FormatsList formats={metadata.formats} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from './types'
|
|
||||||
|
|
||||||
type DownloadRequestArgs = {
|
|
||||||
url: string,
|
|
||||||
args: string,
|
|
||||||
pathOverride?: string,
|
|
||||||
renameTo?: string,
|
|
||||||
playlist?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RPCClient {
|
|
||||||
private seq: number
|
|
||||||
private httpEndpoint: string
|
|
||||||
private readonly _socket$: WebSocket
|
|
||||||
private readonly token?: string
|
|
||||||
|
|
||||||
constructor(httpEndpoint: string, webSocketEndpoint: string, token?: string) {
|
|
||||||
this.seq = 0
|
|
||||||
this.httpEndpoint = httpEndpoint
|
|
||||||
this.token = token
|
|
||||||
this._socket$ = new WebSocket(
|
|
||||||
token ? `${webSocketEndpoint}?token=${token}` : webSocketEndpoint
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Websocket connection
|
|
||||||
*/
|
|
||||||
public get socket() {
|
|
||||||
return this._socket$
|
|
||||||
}
|
|
||||||
|
|
||||||
private incrementSeq() {
|
|
||||||
return String(this.seq++)
|
|
||||||
}
|
|
||||||
|
|
||||||
private send(req: RPCRequest) {
|
|
||||||
this._socket$.send(JSON.stringify({
|
|
||||||
...req,
|
|
||||||
id: this.incrementSeq(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
private argsSanitizer(args: string) {
|
|
||||||
return args
|
|
||||||
.split(' ')
|
|
||||||
.map(a => a.trim().replaceAll("'", '').replaceAll('"', ''))
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendHTTP<T>(req: RPCRequest) {
|
|
||||||
const res = await fetch(this.httpEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-Authentication': this.token ?? ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
...req,
|
|
||||||
id: this.incrementSeq(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const data: RPCResponse<T> = await res.json()
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a new download. Handles arguments sanitization.
|
|
||||||
* @param req payload
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public download(req: DownloadRequestArgs) {
|
|
||||||
if (!req.url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const rename = req.args.includes('-o')
|
|
||||||
? req.args
|
|
||||||
.substring(req.args.indexOf('-o'))
|
|
||||||
.replaceAll("'", '')
|
|
||||||
.replaceAll('"', '')
|
|
||||||
.split('-o')
|
|
||||||
.map(s => s.trim())
|
|
||||||
.join('')
|
|
||||||
.split(' ')
|
|
||||||
.at(0) ?? ''
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const sanitizedArgs = this.argsSanitizer(
|
|
||||||
req.args.replace('-o', '').replace(rename, '')
|
|
||||||
)
|
|
||||||
|
|
||||||
if (req.playlist) {
|
|
||||||
return this.sendHTTP({
|
|
||||||
method: 'Service.ExecPlaylist',
|
|
||||||
params: [{
|
|
||||||
URL: req.url,
|
|
||||||
Params: sanitizedArgs,
|
|
||||||
Path: req.pathOverride,
|
|
||||||
Rename: req.renameTo || rename,
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendHTTP({
|
|
||||||
method: 'Service.Exec',
|
|
||||||
params: [{
|
|
||||||
URL: req.url.split('?list').at(0)!,
|
|
||||||
Params: sanitizedArgs,
|
|
||||||
Path: req.pathOverride,
|
|
||||||
Rename: req.renameTo || rename,
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requests the available formats for a given url (-f arg)
|
|
||||||
* @param url requested url
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public formats(url: string) {
|
|
||||||
if (url) {
|
|
||||||
return this.sendHTTP<DLMetadata>({
|
|
||||||
method: 'Service.Formats',
|
|
||||||
params: [{
|
|
||||||
URL: url.split('?list').at(0)!,
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requests all downloads
|
|
||||||
*/
|
|
||||||
public running() {
|
|
||||||
this.send({
|
|
||||||
method: 'Service.Running',
|
|
||||||
params: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops and removes a download asynchronously
|
|
||||||
* @param id download id
|
|
||||||
*/
|
|
||||||
public kill(id: string) {
|
|
||||||
this.sendHTTP({
|
|
||||||
method: 'Service.Kill',
|
|
||||||
params: [id],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops and removes all downloads
|
|
||||||
*/
|
|
||||||
public killAll() {
|
|
||||||
this.sendHTTP({
|
|
||||||
method: 'Service.KillAll',
|
|
||||||
params: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get asynchronously the avaliable space on downloads directory
|
|
||||||
* @returns free space in bytes
|
|
||||||
*/
|
|
||||||
public freeSpace() {
|
|
||||||
return this.sendHTTP<number>({
|
|
||||||
method: 'Service.FreeSpace',
|
|
||||||
params: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get asynchronously the tree view of the download directory
|
|
||||||
* @returns free space in bytes
|
|
||||||
*/
|
|
||||||
public directoryTree() {
|
|
||||||
return this.sendHTTP<string[]>({
|
|
||||||
method: 'Service.DirectoryTree',
|
|
||||||
params: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates synchronously yt-dlp executable
|
|
||||||
* @returns free space in bytes
|
|
||||||
*/
|
|
||||||
public updateExecutable() {
|
|
||||||
return this.sendHTTP({
|
|
||||||
method: 'Service.UpdateExecutable',
|
|
||||||
params: []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
export let value: any;
|
|
||||||
|
|
||||||
export let disabled: boolean = false;
|
|
||||||
export let placeholder: string = '';
|
|
||||||
export { clazz as class };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<select
|
|
||||||
class="
|
|
||||||
p-2
|
|
||||||
bg-neutral-50
|
|
||||||
border rounded-lg
|
|
||||||
appearance-none
|
|
||||||
text-sm font-semibold
|
|
||||||
focus:outline-blue-300
|
|
||||||
"
|
|
||||||
bind:value
|
|
||||||
{disabled}
|
|
||||||
{placeholder}
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</select>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import Button from './Button.svelte';
|
|
||||||
import TextField from './TextField.svelte';
|
|
||||||
import { rpcClient, rpcHost, rpcPort } from './store';
|
|
||||||
import FullscreenSpinner from './FullscreenSpinner.svelte';
|
|
||||||
|
|
||||||
let loading: Promise<any>;
|
|
||||||
|
|
||||||
const update = () => (loading = get(rpcClient).updateExecutable());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<div class="font-semibold text-lg mb-4">Settings</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
<TextField
|
|
||||||
label="Server address"
|
|
||||||
bind:value={$rpcHost}
|
|
||||||
placeholder="localhost"
|
|
||||||
/>
|
|
||||||
<TextField label="Server port" bind:value={$rpcPort} placeholder="3033" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button class="mt-4" on:click={update}>Update yt-dlp</Button>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
{#await loading}
|
|
||||||
<FullscreenSpinner />
|
|
||||||
{/await}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- <CookiesTextField /> -->
|
|
||||||
</div>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<div role="status">
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-400"
|
|
||||||
viewBox="0 0 100 101"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
|
||||||
fill="currentFill"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let clazz: string = '';
|
|
||||||
export let label: string;
|
|
||||||
export let value: any;
|
|
||||||
|
|
||||||
export let disabled: boolean = false;
|
|
||||||
export let placeholder: string = '';
|
|
||||||
export { clazz as class };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-0.5 text-sm font-semibold">
|
|
||||||
<label for=""> {label} </label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class={`p-2
|
|
||||||
bg-neutral-50 border
|
|
||||||
rounded-lg
|
|
||||||
focus:outline-blue-300
|
|
||||||
dark:bg-neutral-700 dark:border-neutral-900
|
|
||||||
${clazz}
|
|
||||||
`}
|
|
||||||
on:keyup
|
|
||||||
bind:value
|
|
||||||
{placeholder}
|
|
||||||
{disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { tryCatch } from 'fp-ts/TaskEither'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* functional fetch(): composable as TaskEither
|
|
||||||
*/
|
|
||||||
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
|
||||||
() => fetcher<T>(url, opt),
|
|
||||||
(e) => `error while fetching: ${e}`
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
|
||||||
const jwt = localStorage.getItem('token')
|
|
||||||
|
|
||||||
if (opt && !opt.headers) {
|
|
||||||
opt.headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
...opt,
|
|
||||||
headers: {
|
|
||||||
...opt?.headers,
|
|
||||||
'X-Authentication': jwt ?? ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw await res.text()
|
|
||||||
}
|
|
||||||
return res.json() as T
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import * as O from 'fp-ts/lib/Option'
|
|
||||||
import { derived, readable, writable } from 'svelte/store'
|
|
||||||
import { RPCClient } from './RPCClient'
|
|
||||||
import { type CustomTemplate, type RPCResult } from './types'
|
|
||||||
|
|
||||||
export const rpcHost = writable<string>(localStorage.getItem('rpcHost') ?? 'localhost')
|
|
||||||
export const rpcPort = writable<number>(Number(localStorage.getItem('rpcPort')) || 3033)
|
|
||||||
|
|
||||||
// if authentication is enabled...
|
|
||||||
export const rpcWebToken = writable<string>(localStorage.getItem('rpcWebToken') ?? '')
|
|
||||||
|
|
||||||
// will be used to access the api and archive endpoints
|
|
||||||
export const serverApiEndpoint = derived(
|
|
||||||
[rpcHost, rpcPort],
|
|
||||||
([$host, $port]) => window.location.port == ''
|
|
||||||
? `${window.location.protocol}//${$host}`
|
|
||||||
: `${window.location.protocol}//${$host}:${$port}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// access the websocket JSON-RPC 1.0 to gather downloads state
|
|
||||||
export const websocketRpcEndpoint = derived(
|
|
||||||
[rpcHost, rpcPort],
|
|
||||||
([$host, $port]) => window.location.port == ''
|
|
||||||
? `${window.location.protocol.startsWith('https') ? 'wss:' : 'ws:'}//${$host}/rpc/ws`
|
|
||||||
: `${window.location.protocol.startsWith('https') ? 'wss:' : 'ws:'}//${$host}:${$port}/rpc/ws`
|
|
||||||
)
|
|
||||||
|
|
||||||
// same as websocket one but using HTTP-POST mainly used to send commands (download, stop, ...)
|
|
||||||
export const httpPostRpcEndpoint = derived(
|
|
||||||
serverApiEndpoint,
|
|
||||||
$ep => window.location.port == '' ? `${$ep}/rpc/http` : `${$ep}/rpc/http`
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Will handle Websocket and HTTP-POST communications based on the requested method
|
|
||||||
*/
|
|
||||||
export const rpcClient = derived(
|
|
||||||
[httpPostRpcEndpoint, websocketRpcEndpoint, rpcWebToken],
|
|
||||||
([$http, $ws, $token]) => new RPCClient($http, $ws, $token)
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores all the downloads returned by the rpc
|
|
||||||
*/
|
|
||||||
export const downloads = writable<O.Option<RPCResult[]>>(O.none)
|
|
||||||
|
|
||||||
export const cookiesTemplate = writable<string>('')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fetches download templates, needs manual update
|
|
||||||
*/
|
|
||||||
export const downloadTemplates = readable<CustomTemplate[]>([], (set) => {
|
|
||||||
serverApiEndpoint
|
|
||||||
.subscribe(ep => fetch(`${ep}/api/v1/template/all`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => set(data)))
|
|
||||||
})
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
export type RPCMethods =
|
|
||||||
| "Service.Exec"
|
|
||||||
| "Service.Kill"
|
|
||||||
| "Service.Clear"
|
|
||||||
| "Service.Running"
|
|
||||||
| "Service.KillAll"
|
|
||||||
| "Service.FreeSpace"
|
|
||||||
| "Service.Formats"
|
|
||||||
| "Service.ExecPlaylist"
|
|
||||||
| "Service.DirectoryTree"
|
|
||||||
| "Service.UpdateExecutable"
|
|
||||||
|
|
||||||
export type RPCRequest = {
|
|
||||||
method: RPCMethods
|
|
||||||
params?: any[]
|
|
||||||
id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RPCResponse<T> = Readonly<{
|
|
||||||
result: T
|
|
||||||
error: number | null
|
|
||||||
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
|
|
||||||
process_status: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RPCResult = Readonly<{
|
|
||||||
id: string
|
|
||||||
progress: DownloadProgress
|
|
||||||
info: DownloadInfo
|
|
||||||
}>
|
|
||||||
|
|
||||||
export type RPCParams = {
|
|
||||||
URL: string
|
|
||||||
Params?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DLMetadata = {
|
|
||||||
formats: Array<DLFormat>
|
|
||||||
best: DLFormat
|
|
||||||
thumbnail: string
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DLFormat = {
|
|
||||||
format_id: string
|
|
||||||
format_note: string
|
|
||||||
fps: number
|
|
||||||
resolution: string
|
|
||||||
vcodec: string
|
|
||||||
acodec: string
|
|
||||||
filesize_approx: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DirectoryEntry = {
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
size: number
|
|
||||||
shaSum: string
|
|
||||||
modTime: string
|
|
||||||
isVideo: boolean
|
|
||||||
isDirectory: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
|
|
||||||
|
|
||||||
export type PlayRequest = Pick<DirectoryEntry, 'path'>
|
|
||||||
|
|
||||||
export type CustomTemplate = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { pipe } from 'fp-ts/lib/function'
|
|
||||||
import type { RPCResponse } from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an ip v4 via regex
|
|
||||||
* @param {string} ipAddr
|
|
||||||
* @returns ip validity test
|
|
||||||
*/
|
|
||||||
export function validateIP(ipAddr: string): boolean {
|
|
||||||
let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm
|
|
||||||
return ipRegex.test(ipAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateDomain(url: string): boolean {
|
|
||||||
const urlRegex = /(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
|
|
||||||
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
||||||
|
|
||||||
const [name, slug] = url.split('/')
|
|
||||||
|
|
||||||
return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidURL(url: string): boolean {
|
|
||||||
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
|
|
||||||
return urlRegex.test(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ellipsis(str: string, lim: number): string {
|
|
||||||
if (str) {
|
|
||||||
return str.length > lim ? `${str.substring(0, lim)}...` : str
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toFormatArgs(codes: string[]): string {
|
|
||||||
if (codes.length > 1) {
|
|
||||||
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
|
||||||
}
|
|
||||||
if (codes.length === 1) {
|
|
||||||
return ` -f ${codes[0]}`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatGiB = (bytes: number) =>
|
|
||||||
`${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
|
||||||
|
|
||||||
export const roundMiB = (bytes: number) =>
|
|
||||||
`${(bytes / 1_000_000).toFixed(2)} MiB`
|
|
||||||
|
|
||||||
export const formatSpeedMiB = (val: number) =>
|
|
||||||
`${roundMiB(val)}/s`
|
|
||||||
|
|
||||||
export const datetimeCompareFunc = (a: string, b: string) =>
|
|
||||||
new Date(a).getTime() - new Date(b).getTime()
|
|
||||||
|
|
||||||
export function isRPCResponse(object: any): object is RPCResponse<any> {
|
|
||||||
return 'result' in object && 'id' in object
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapProcessStatus(status: number) {
|
|
||||||
switch (status) {
|
|
||||||
case 0:
|
|
||||||
return 'Pending'
|
|
||||||
case 1:
|
|
||||||
return 'Downloading'
|
|
||||||
case 2:
|
|
||||||
return 'Completed'
|
|
||||||
case 3:
|
|
||||||
return 'Error'
|
|
||||||
default:
|
|
||||||
return 'Pending'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prefersDarkMode = () =>
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
|
|
||||||
export const base64URLEncode = (s: string) => pipe(
|
|
||||||
s,
|
|
||||||
s => String.fromCodePoint(...new TextEncoder().encode(s)),
|
|
||||||
btoa,
|
|
||||||
encodeURIComponent
|
|
||||||
)
|
|
||||||
|
|
||||||
export const debounce = (callback: Function, wait = 300) => {
|
|
||||||
let timeout: ReturnType<typeof setTimeout>
|
|
||||||
|
|
||||||
return (...args: any[]) => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
timeout = setTimeout(() => callback(...args), wait)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import './app.css'
|
|
||||||
import '@fontsource/roboto'
|
|
||||||
import '@fontsource/roboto/400-italic.css'
|
|
||||||
import '@fontsource/roboto/400.css'
|
|
||||||
import App from './App.svelte'
|
|
||||||
|
|
||||||
const app = new App({
|
|
||||||
target: document.getElementById('app'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { pipe } from 'fp-ts/lib/function';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import DownloadCard from '../lib/DownloadCard.svelte';
|
|
||||||
import Spinner from '../lib/Spinner.svelte';
|
|
||||||
import { downloads, rpcClient } from '../lib/store';
|
|
||||||
import { datetimeCompareFunc, isRPCResponse } from '../lib/utils';
|
|
||||||
|
|
||||||
const unsubscribe = rpcClient.subscribe(($client) => {
|
|
||||||
setInterval(() => $client.running(), 750);
|
|
||||||
|
|
||||||
$client.socket.onmessage = (ev: any) => {
|
|
||||||
const event = JSON.parse(ev.data);
|
|
||||||
// guards
|
|
||||||
if (!isRPCResponse(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(event.result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.result) {
|
|
||||||
return downloads.set(
|
|
||||||
O.of(
|
|
||||||
event.result
|
|
||||||
.filter((f) => !!f.info.url)
|
|
||||||
.sort((a, b) =>
|
|
||||||
datetimeCompareFunc(b.info.created_at, a.info.created_at),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloads.set(O.none);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(unsubscribe);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if O.isNone($downloads)}
|
|
||||||
<div class="h-[90vh] w-full flex justify-center items-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2 p-8">
|
|
||||||
{#each pipe( $downloads, O.getOrElseW(() => []), ) as download}
|
|
||||||
<DownloadCard {download} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Settings from '../lib/Settings.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main
|
|
||||||
class="bg-neutral-100 dark:bg-neutral-800
|
|
||||||
rounded-xl
|
|
||||||
border dark:border-neutral-700
|
|
||||||
shadow-lg
|
|
||||||
m-8 p-4"
|
|
||||||
>
|
|
||||||
<Settings />
|
|
||||||
</main>
|
|
||||||
2
ui/src/vite-env.d.ts
vendored
2
ui/src/vite-env.d.ts
vendored
@@ -1,2 +0,0 @@
|
|||||||
/// <reference types="svelte" />
|
|
||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
|
||||||
// for more information about preprocessors
|
|
||||||
preprocess: vitePreprocess(),
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{svelte,js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
/**
|
|
||||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
|
||||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
|
||||||
* Note that setting allowJs false does not prevent the use
|
|
||||||
* of JS in `.svelte` files.
|
|
||||||
*/
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"isolatedModules": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user