Compare commits

..

35 Commits

Author SHA1 Message Date
589468ed0e defined grpc proto3 file 2024-06-04 11:22:26 +02:00
7c86e1dd23 updated Makefile 2024-06-04 11:21:54 +02:00
ed79e70ee3 fixed duplicate store key 2024-06-04 11:21:07 +02:00
8efa72c964 code refactoring 2024-06-04 11:04:48 +02:00
d4a35f1d1d Support for reverse proxy subdir.
Closes #110 #150
2024-06-04 10:49:55 +02:00
4013a66b04 stream downloads zip archive 2024-06-03 11:03:16 +02:00
4cc1ed681a fix ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION
closes #154
2024-06-03 10:52:05 +02:00
Marco Piovanello
306e673f59 Update README.md 2024-05-24 15:55:11 +02:00
22e80893f3 swagger 2024-05-24 15:53:11 +02:00
f2389a6e6a Fixed nil logger pointer in rest endpoints
Closes #153
2024-05-24 13:59:03 +02:00
Jordy
e0e923822c Add docker compose example (#144)
* Add docker compose example

* compose: healthcheck and restart policy

* Update README.md
2024-05-20 09:17:17 +02:00
46926eb873 removed utils package 2024-05-20 08:59:39 +02:00
472db89ea3 load balancer implementation, code refactoring 2024-05-20 08:48:01 +02:00
Jakub Tarnawski
fee7f86182 Update README.md (#149)
Adding some hopefully helpful hints on running yt-dlp-webui as a systemd daemon
2024-05-18 21:31:29 +02:00
Marco Piovanello
1da32f3c65 Update issue templates 2024-05-16 11:42:28 +02:00
c10f60d4d4 async metadata provider 2024-05-16 11:05:08 +02:00
da84eb14f3 changed channel based approach to sync/semaphore 2024-05-16 10:39:29 +02:00
f5f0af7e1e fixed potential goroutine deadlock in message queue 2024-05-16 10:10:42 +02:00
Tobias
ace6621d4a Corrected a Typo (#148) 2024-05-16 10:02:56 +02:00
e0202ff631 restored original queue size 2024-05-14 11:47:32 +02:00
8f10baf09b changed to an "event bus" approach in the message queue, fixed unset download parameters. 2024-05-14 11:21:02 +02:00
62eadb52a6 use message queue to restore download from session file 2024-05-13 11:15:08 +02:00
6abfb57598 fixed playlist download not setting metadata 2024-05-13 11:04:16 +02:00
dc2828b884 re-added gnu wget to base container 2024-05-10 11:50:27 +02:00
d58a2e6692 dependecies update 2024-05-10 11:50:10 +02:00
00bacf5c41 comments and code refactoring 2024-04-24 11:52:14 +02:00
01c327d308 fix nil pointer 2024-04-24 11:10:42 +02:00
83f6444df2 migrated from alpine to wolfi 2024-04-24 11:02:12 +02:00
e09c52dd05 migrated from alpine to wolfi 2024-04-24 10:49:46 +02:00
3da81affb3 better error logging 2024-04-22 10:03:16 +02:00
a73b8d362e fix broken alpine:edge yt-dlp build 2024-04-18 11:05:44 +02:00
205f2e5cdf Implemented "download file" in dashboard and bulk download
closes #115
2024-04-16 11:27:47 +02:00
294ad29bf2 Fixed possible nil logger in playlist download
Fixes #145
2024-04-12 22:35:05 +02:00
d336b92e46 code refactoring 2024-04-10 12:52:17 +02:00
2f02293a52 code refactoring 2024-04-10 12:02:04 +02:00
51 changed files with 2437 additions and 1494 deletions

View File

@@ -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/

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help improve the project
title: ''
labels: ''
assignees: ''
---
**Version running**
Provide the docker label or release tag you're running.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@@ -25,14 +25,16 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# dependencies ---------------------------------------------------------------- # dependencies ----------------------------------------------------------------
FROM alpine:edge FROM cgr.dev/chainguard/wolfi-base
RUN apk update && \
apk add ffmpeg ca-certificates python3 py3-pip wget
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

View File

@@ -1,11 +1,17 @@
.PHONY : fe clean all
default: default:
CGO_ENABLED=0 go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
fe:
cd frontend && pnpm build
all: all:
cd frontend && pnpm build && cd .. $(MAKE) fe && cd ..
CGO_ENABLED=0 go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
multiarch: multiarch:
$(MAKE) fe
mkdir -p build mkdir -p build
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 GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go

View File

@@ -92,6 +92,7 @@ To enable it just go to the settings page and enable the **Enable video/audio fo
- As before server address is not specified or simply yt-dlp process takes a lot of time to fire up. (Forking yt-dlp isn't fast especially if you have a lower-end/low-power NAS/server/desktop where the server is running) - As before server address is not specified or simply yt-dlp process takes a lot of time to fire up. (Forking yt-dlp isn't fast especially if you have a lower-end/low-power NAS/server/desktop where the server is running)
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation ## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
## Docker run
```sh ```sh
docker pull marcobaobao/yt-dlp-webui docker pull marcobaobao/yt-dlp-webui
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
@@ -135,6 +136,20 @@ docker run -d \
--qs 2 --qs 2
``` ```
### Docker Compose
```yaml
services:
yt-dlp-webui:
image: marcobaobao/yt-dlp-webui
ports:
- 3033:3033
volumes:
- <your dir>:/downloads # replace <your dir> with a directory on your host system
healthcheck:
test: curl -f http://localhost:3033 || exit 1
restart: unless-stopped
```
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation ## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
```sh ```sh
@@ -229,6 +244,29 @@ WantedBy=multi-user.target
systemctl enable yt-dlp-webui systemctl enable yt-dlp-webui
systemctl start yt-dlp-webui systemctl start yt-dlp-webui
``` ```
It could be that yt-dlp-webui works correctly when started manually from the console, but with systemd, it does not see the yt-dlp executable, or has issues writing to the database file. One way to fix these issues could be as follows:
```shell
cd
mkdir yt-dlp-webui-workingdir
# optionally move the already existing database file there:
mv local.db yt-dlp-webui-workingdir
nano yt-dlp-webui-workingdir/my.conf
```
The config file format is described above; make sure to include the `downloaderPath` setting (the path can possibly be found by running `which yt-dlp`). For example, one could have:
```
downloadPath: /stuff/media
downloaderPath: /home/your_user/.local/bin/yt-dlp
log_path: /home/your_user/yt-dlp-webui-workingdir
session_file_path: /home/your_user/yt-dlp-webui-workingdir
```
Adjust the Service section in the `/etc/systemd/system/yt-dlp-webui.service` file as follows:
```
[Service]
User=your_user
Group=your_user
WorkingDirectory=/home/your_user/yt-dlp-webui-workingdir
ExecStart=/usr/local/bin/yt-dlp-webui --conf /home/your_user/yt-dlp-webui-workingdir/my.conf
```
## Manual installation ## Manual installation
```sh ```sh
@@ -240,6 +278,9 @@ npm run build
go build -o yt-dlp-webui main.go go build -o yt-dlp-webui main.go
``` ```
## Open-API
Navigate to `/openapi` to see the related swagger.
## Extendable ## Extendable
You dont'like the Material feel? You dont'like the Material feel?

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
yt-dlp-webui:
image: marcobaobao/yt-dlp-webui
ports:
- 3033:3033
volumes:
- <your dir>:/downloads # replace <your dir> with a directory on your host system
healthcheck:
test: curl -f http://localhost:3033 || exit 1
restart: unless-stopped

View File

@@ -1,6 +1,6 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "2.10.0", "version": "3.0.8",
"description": "Frontend compontent of yt-dlp-webui", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,16 +10,16 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.5",
"@fontsource/roboto": "^5.0.8", "@fontsource/roboto": "^5.0.13",
"@fontsource/roboto-mono": "^5.0.16", "@fontsource/roboto-mono": "^5.0.18",
"@mui/icons-material": "^5.15.4", "@mui/icons-material": "^5.15.16",
"@mui/material": "^5.15.4", "@mui/material": "^5.15.16",
"fp-ts": "^2.16.2", "fp-ts": "^2.16.5",
"react": "^18.2.0", "react": "^18.3.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-router-dom": "^6.21.2", "react-router-dom": "^6.23.0",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -32,7 +32,7 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.6.0",
"typescript": "^5.4.3", "typescript": "^5.4.3",
"vite": "^5.2.6", "vite": "^5.2.11",
"million": "^3.0.6" "million": "^3.0.6"
} }
} }

2403
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,8 @@ languages:
clipboardAction: Copied URL to clipboard clipboardAction: Copied URL to clipboard
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window) playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
restartAppMessage: Needs a page reload to take effect restartAppMessage: Needs a page reload to take effect
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy
urlBase: URL base, for reverse proxy support (subdir), defaults to empty
newDownloadButton: New download newDownloadButton: New download
homeButtonLabel: Home homeButtonLabel: Home
archiveButtonLabel: Archive archiveButtonLabel: Archive
@@ -49,6 +50,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 +100,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,6 +152,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'
italian: italian:
urlInput: URL Video (uno per linea) urlInput: URL Video (uno per linea)
statusTitle: Stato statusTitle: Stato
@@ -197,6 +201,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 +251,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 +299,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 +347,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 +395,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 +444,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 +492,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 +540,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 +588,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'

View File

@@ -135,6 +135,15 @@ export const servedFromReverseProxyState = atom({
] ]
}) })
export const servedFromReverseProxySubDirState = atom<string>({
key: 'servedFromReverseProxySubDirState',
default: localStorage.getItem('reverseProxySubDir') ?? '',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxySubDir', a))
]
})
export const appTitleState = atom({ export const appTitleState = atom({
key: 'appTitleState', key: 'appTitleState',
default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI', default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI',
@@ -146,9 +155,15 @@ export const appTitleState = atom({
export const serverAddressAndPortState = selector({ export const serverAddressAndPortState = selector({
key: 'serverAddressAndPortState', key: 'serverAddressAndPortState',
get: ({ get }) => get(servedFromReverseProxyState) get: ({ get }) => {
? `${get(serverAddressState)}` if (get(servedFromReverseProxySubDirState)) {
: `${get(serverAddressState)}:${get(serverPortState)}` return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
}
if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}`
}
return `${get(serverAddressState)}:${get(serverPortState)}`
}
}) })
export const serverURL = selector({ export const serverURL = selector({

View File

@@ -1,5 +1,4 @@
import { atom, selector } from 'recoil' import { atom, selector } from 'recoil'
import { RPCResult } from '../types'
import { activeDownloadsState } from './downloads' import { activeDownloadsState } from './downloads'
export const loadingAtom = atom({ export const loadingAtom = atom({
@@ -7,11 +6,6 @@ export const loadingAtom = atom({
default: true default: true
}) })
export const optimisticDownloadsState = atom<RPCResult[]>({
key: 'optimisticDownloadsState',
default: []
})
export const totalDownloadSpeedState = selector<number>({ export const totalDownloadSpeedState = selector<number>({
key: 'totalDownloadSpeedState', key: 'totalDownloadSpeedState',
get: ({ get }) => get(activeDownloadsState) get: ({ get }) => get(activeDownloadsState)

View File

@@ -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, formatSize } 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={() => {
@@ -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>
) )

View File

@@ -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(
@@ -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))
} }

View File

@@ -1,14 +1,15 @@
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import DownloadIcon from '@mui/icons-material/Download' import DownloadIcon from '@mui/icons-material/Download'
import DownloadDoneIcon from '@mui/icons-material/DownloadDone' 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 StopCircleIcon from '@mui/icons-material/StopCircle'
import { import {
Box, Box,
Grid, ButtonGroup,
IconButton, IconButton,
LinearProgress, LinearProgress,
LinearProgressProps, LinearProgressProps,
Paper,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -19,8 +20,9 @@ import {
} from "@mui/material" } from "@mui/material"
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads' import { activeDownloadsState } from '../atoms/downloads'
import { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { formatSize, formatSpeedMiB } from "../utils" import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) { function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) {
return ( return (
@@ -38,12 +40,23 @@ function LinearProgressWithLabel(props: LinearProgressProps & { value: number })
} }
const DownloadsTableView: React.FC = () => { const DownloadsTableView: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const downloads = useRecoilValue(activeDownloadsState) const downloads = useRecoilValue(activeDownloadsState)
const { client } = useRPC() const { client } = useRPC()
const abort = (id: string) => client.kill(id) 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 ( return (
<TableContainer <TableContainer
sx={{ minHeight: '80vh', mt: 4 }} sx={{ minHeight: '80vh', mt: 4 }}
@@ -108,6 +121,7 @@ const DownloadsTableView: React.FC = () => {
{new Date(download.info.created_at).toLocaleString()} {new Date(download.info.created_at).toLocaleString()}
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
<ButtonGroup>
<IconButton <IconButton
size="small" size="small"
onClick={() => abort(download.id)} onClick={() => abort(download.id)}
@@ -115,6 +129,23 @@ const DownloadsTableView: React.FC = () => {
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />} {download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}
</IconButton> </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> </TableCell>
</TableRow> </TableRow>
)) ))

View File

@@ -39,7 +39,7 @@ const ErrorBoundary: React.FC = () => {
</Button> </Button>
</Link> </Link>
<Typography sx={{ mt: 2 }} color={'gray'} fontWeight={500}> <Typography sx={{ mt: 2 }} color={'gray'} fontWeight={500}>
Or login if authentication is enabled Or login if authentification is enabled
</Typography> </Typography>
<Link to={'/login'} > <Link to={'/login'} >
<Button variant='contained' sx={{ mt: 2 }}> <Button variant='contained' sx={{ mt: 2 }}>

View File

@@ -35,7 +35,7 @@ const Footer: React.FC = () => {
display: 'flex', gap: 1, justifyContent: 'space-between' display: 'flex', gap: 1, justifyContent: 'space-between'
}}> }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<Chip label="RPC v3.0.6" variant="outlined" size="small" /> <Chip label="RPC v3.0.8" variant="outlined" size="small" />
<VersionIndicator /> <VersionIndicator />
</div> </div>
<div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}> <div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}>

View File

@@ -1,6 +1,6 @@
import { Suspense, useState } from 'react' import { Suspense, useState } from 'react'
import { useRecoilState } from 'recoil' import { useRecoilState } from 'recoil'
import { loadingAtom, optimisticDownloadsState } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import DownloadDialog from './DownloadDialog' import DownloadDialog from './DownloadDialog'
import HomeSpeedDial from './HomeSpeedDial' import HomeSpeedDial from './HomeSpeedDial'
@@ -8,32 +8,12 @@ import TemplatesEditor from './TemplatesEditor'
const HomeActions: React.FC = () => { const HomeActions: React.FC = () => {
const [, setIsLoading] = useRecoilState(loadingAtom) const [, setIsLoading] = useRecoilState(loadingAtom)
const [optimistic, setOptimistic] = useRecoilState(optimisticDownloadsState)
const [openDownload, setOpenDownload] = useState(false) const [openDownload, setOpenDownload] = useState(false)
const [openEditor, setOpenEditor] = useState(false) const [openEditor, setOpenEditor] = useState(false)
const { pushMessage } = useToast() const { pushMessage } = useToast()
// it's stupid because it will be overriden on the next server tick
const handleOptimisticUpdate = (url: string) => setOptimistic([
...optimistic, {
id: url,
info: {
created_at: new Date().toISOString(),
thumbnail: '',
title: url,
url: url
},
progress: {
eta: Number.MAX_SAFE_INTEGER,
percentage: '0%',
process_status: 0,
speed: 0
}
}
])
return ( return (
<> <>
<HomeSpeedDial <HomeSpeedDial
@@ -49,7 +29,6 @@ const HomeActions: React.FC = () => {
}} }}
// TODO: handle optimistic UI update // TODO: handle optimistic UI update
onDownloadStart={(url) => { onDownloadStart={(url) => {
handleOptimisticUpdate(url)
pushMessage(`Requested ${url}`, 'info') pushMessage(`Requested ${url}`, 'info')
setOpenDownload(false) setOpenDownload(false)
setIsLoading(true) setIsLoading(true)

View File

@@ -3,13 +3,14 @@ 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 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'
@@ -19,6 +20,7 @@ type Props = {
} }
const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => { const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const serverAddr = useRecoilValue(serverURL)
const [listView, setListView] = useRecoilState(listViewState) const [listView, setListView] = useRecoilState(listViewState)
const { i18n } = useI18n() const { i18n } = useI18n()
@@ -37,6 +39,11 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
tooltipTitle={listView ? 'Card view' : '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')}

View File

@@ -6,7 +6,6 @@ import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css' import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css' import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css' import '@fontsource/roboto/700.css'
import '@fontsource/roboto/700.css'
import '@fontsource/roboto-mono' import '@fontsource/roboto-mono'

View File

@@ -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 = {

View File

@@ -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) {

View File

@@ -39,6 +39,7 @@ import {
latestCliArgumentsState, latestCliArgumentsState,
pathOverridingState, pathOverridingState,
servedFromReverseProxyState, servedFromReverseProxyState,
servedFromReverseProxySubDirState,
serverAddressState, serverAddressState,
serverPortState, serverPortState,
themeState themeState
@@ -53,6 +54,7 @@ import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS // NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() { export default function Settings() {
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState) const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
const [baseURL, setBaseURL] = useRecoilState(servedFromReverseProxySubDirState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState) const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState) const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState) const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
@@ -73,9 +75,20 @@ export default function Settings() {
const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), []) const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), [])
const baseURL$ = useMemo(() => new Subject<string>(), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), []) const serverPort$ = useMemo(() => new Subject<string>(), [])
useEffect(() => {
const sub = baseURL$
.pipe(debounceTime(500))
.subscribe(baseURL => {
setBaseURL(baseURL)
pushMessage(i18n.t('restartAppMessage'), 'info')
})
return () => sub.unsubscribe()
}, [])
useEffect(() => { useEffect(() => {
const sub = serverAddr$ const sub = serverAddr$
.pipe( .pipe(
@@ -145,7 +158,7 @@ export default function Settings() {
minHeight: 240, minHeight: 240,
}} }}
> >
<Typography pb={3} variant="h5" color="primary"> <Typography pb={2} variant="h6" color="primary">
{i18n.t('settingsAnchor')} {i18n.t('settingsAnchor')}
</Typography> </Typography>
<FormGroup> <FormGroup>
@@ -183,6 +196,9 @@ export default function Settings() {
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h6" color="primary" sx={{ mb: 0.5 }}>
Reverse Proxy
</Typography>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
@@ -191,10 +207,29 @@ export default function Settings() {
/> />
} }
label={i18n.t('servedFromReverseProxyCheckbox')} label={i18n.t('servedFromReverseProxyCheckbox')}
sx={{ mb: 1 }}
/>
<TextField
fullWidth
label={i18n.t('urlBase')}
defaultValue={baseURL}
onChange={(e) => {
let value = e.currentTarget.value
if (value.startsWith('/')) {
value = value.substring(1)
}
if (value.endsWith('/')) {
value = value.substring(0, value.length - 1)
}
baseURL$.next(value)
}}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> />
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 0.5, mb: 2 }}>
Appaerance
</Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<FormControl fullWidth> <FormControl fullWidth>
@@ -227,6 +262,9 @@ export default function Settings() {
</FormControl> </FormControl>
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
General download settings
</Typography>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -235,7 +273,7 @@ export default function Settings() {
/> />
} }
label={i18n.t('noMTimeCheckbox')} label={i18n.t('noMTimeCheckbox')}
sx={{ mt: 3 }}
/> />
<FormControlLabel <FormControlLabel
control={ control={
@@ -299,7 +337,7 @@ export default function Settings() {
/> />
</Stack> </Stack>
</Grid> </Grid>
<Grid sx={{ mr: 1, mt: 3 }}> <Grid sx={{ mr: 1, mt: 2 }}>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}> <Typography variant="h6" color="primary" sx={{ mb: 2 }}>
Cookies Cookies
</Typography> </Typography>

3
go.mod
View File

@@ -3,12 +3,14 @@ module github.com/marcopeocchi/yt-dlp-web-ui
go 1.22 go 1.22
require ( require (
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
github.com/go-chi/chi/v5 v5.0.12 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.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/reactivex/rxgo/v2 v2.5.0 github.com/reactivex/rxgo/v2 v2.5.0
golang.org/x/sync v0.6.0
golang.org/x/sys v0.18.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.29.5 modernc.org/sqlite v1.29.5
@@ -28,7 +30,6 @@ require (
github.com/stretchr/testify v1.9.0 // indirect github.com/stretchr/testify v1.9.0 // indirect
github.com/teivah/onecontext v1.3.0 // indirect github.com/teivah/onecontext v1.3.0 // indirect
golang.org/x/net v0.22.0 // indirect golang.org/x/net v0.22.0 // indirect
golang.org/x/sync v0.6.0 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.47.0 // indirect modernc.org/libc v1.47.0 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect

2
go.sum
View File

@@ -1,3 +1,5 @@
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
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/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 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/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=

15
main.go
View File

@@ -36,13 +36,15 @@ var (
//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
//go:embed openapi/*
swagger embed.FS
) )
func init() { func init() {
flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at") flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at")
flag.IntVar(&port, "port", 3033, "Port where server will listen at") flag.IntVar(&port, "port", 3033, "Port where server will listen at")
flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size") flag.IntVar(&queueSize, "qs", 2, "Queue size (concurrent downloads)")
flag.StringVar(&configFile, "conf", "./config.yml", "Config file path") flag.StringVar(&configFile, "conf", "./config.yml", "Config file path")
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
@@ -62,7 +64,6 @@ func init() {
func main() { func main() {
frontend, err := fs.Sub(frontend, "frontend/dist") frontend, err := fs.Sub(frontend, "frontend/dist")
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
@@ -80,6 +81,11 @@ func main() {
c.Username = username c.Username = username
c.Password = password c.Password = password
// limit concurrent downloads for systems with 2 or less logical cores
if runtime.NumCPU() <= 2 {
c.QueueSize = 1
}
// if config file is found it will be merged with the current config struct // if config file is found it will be merged with the current config struct
if err := c.LoadFile(configFile); err != nil { if err := c.LoadFile(configFile); err != nil {
log.Println(cli.BgRed, "config", cli.Reset, err) log.Println(cli.BgRed, "config", cli.Reset, err)
@@ -88,9 +94,10 @@ func main() {
server.RunBlocking(&server.RunConfig{ server.RunBlocking(&server.RunConfig{
Host: c.Host, Host: c.Host,
Port: c.Port, Port: c.Port,
App: frontend,
DBPath: localDatabasePath, DBPath: localDatabasePath,
FileLogging: enableFileLogging, FileLogging: enableFileLogging,
LogFile: logFile, LogFile: logFile,
App: frontend,
Swagger: swagger,
}) })
} }

25
openapi/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.17.2/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.17.2/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: './openapi.json',
dom_id: '#swagger-ui',
});
};
</script>
</body>
</html>

273
openapi/openapi.json Normal file
View File

@@ -0,0 +1,273 @@
{
"openapi": "3.1.0",
"info": {
"title": "Swagger yt-dlp-webui - OpenAPI 3.1",
"description": "yt-dlp-webui api based on the OpenAPI 3.1 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). \n\nSome useful links:\n- [yt-dlp-webui repository](https://github.com/marcopeocchi/yt-dlp-web-ui)",
"termsOfService": "http://swagger.io/terms/",
"contact": {
"email": "apiteam@swagger.io"
},
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "1.0.11"
},
"externalDocs": {
"description": "Find out more about Swagger",
"url": "http://swagger.io"
},
"servers": [
{
"url": "/api/v1"
}
],
"tags": [
{
"name": "download",
"description": "Everything about your Pets",
"externalDocs": {
"description": "Find out more",
"url": "https://github.com/marcopeocchi/yt-dlp-web-ui"
}
}
],
"paths": {
"/exec": {
"post": {
"tags": [
"download"
],
"summary": "Add a new download in the pending state ready to be processed",
"description": "Add a new download in the pending state ready to be processed",
"operationId": "addDownload",
"requestBody": {
"description": "Create a new download",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DownloadRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful operation",
"content": {
"application/json": {
"schema": {
"type": "string",
"description": "Process uuid"
}
}
}
},
"400": {
"description": "Invalid input"
},
"422": {
"description": "Validation exception"
}
},
"security": [
{
"api_key": [
"write:download",
"read:download"
]
}
]
}
},
"/running": {
"get": {
"tags": [
"download"
],
"summary": "Returns all running and pending process",
"description": "Returns all running and pending process",
"operationId": "running",
"responses": {
"200": {
"description": "Successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProcessResponse"
}
}
}
},
"400": {
"description": "Invalid input"
},
"422": {
"description": "Validation exception"
}
},
"security": [
{
"api_key": [
"write:download",
"read:download"
]
}
]
}
}
},
"components": {
"schemas": {
"DownloadRequest": {
"type": "object",
"properties": {
"url": {
"type": "string",
"examples": [
"https://..."
]
},
"params": {
"type": "array",
"format": "string",
"examples": [
"-N",
"4",
"-R",
"infinite"
]
}
}
},
"DownloadResponse": {
"type": "object",
"properties": {
"url": {
"type": "string",
"examples": [
"https://..."
]
},
"params": {
"type": "array",
"format": "string",
"examples": [
"-N",
"4",
"-R",
"infinite"
]
}
}
},
"DownloadProgress": {
"type": "object",
"properties": {
"process_status": {
"type": "integer",
"examples": [
0,
1,
2,
3
]
},
"percentage": {
"type": "string",
"examples": [
"50%"
]
},
"speed": {
"type": "integer",
"examples": [
7289347
]
},
"eta": {
"type": "integer",
"examples": [
3600
]
}
}
},
"DownloadInfo": {
"type": "object",
"properties": {
"url": {
"type": "string"
},
"title": {
"type": "string"
},
"thumbnail": {
"type": "string"
},
"resolution": {
"type": "string"
},
"size": {
"type": "integer"
},
"vcodec": {
"type": "string"
},
"acodec": {
"type": "string"
},
"extension": {
"type": "string"
},
"original_url": {
"type": "string"
},
"created_at": {
"type": "object"
}
}
},
"DownloadOutput": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"filename": {
"type": "string"
},
"saveFilePath": {
"type": "string"
}
}
},
"ProcessResponse": {
"type": "object",
"properties": {
"progress": {
"$ref": "#/components/schemas/DownloadProgress"
},
"info": {
"$ref": "#/components/schemas/DownloadInfo"
},
"output": {
"$ref": "#/components/schemas/DownloadOutput"
},
"params": {
"type": "array",
"format": "string"
}
}
}
},
"securitySchemes": {
"api_key": {
"type": "apiKey",
"name": "api_key",
"in": "header"
}
}
}
}

65
proto/yt-dlp.proto Normal file
View File

@@ -0,0 +1,65 @@
syntax = "proto3";
message Empty {}
message BaseRequest {
string id = 1;
string url = 2;
}
message DownloadRequest {
string id = 1;
string url = 2;
string path = 3;
string rename = 4;
repeated string params = 5;
}
message ExecResponse {
string id = 1;
}
message DownloadProgress {
int32 status = 1;
string percentage = 2;
float speed = 3;
float eta = 4;
}
message DownloadInfo {
string url = 1;
string title = 2;
string thumbnail = 3;
string resolution = 4;
int32 size = 5;
string vcodec = 6;
string acodec = 7;
string extension = 8;
string originalURL = 9;
string createdAt = 10;
}
message DownloadOutput {
string path = 1;
string filename = 2;
string savedFilePath = 3;
}
message ProcessResponse {
string id = 1;
DownloadProgress progress = 2;
DownloadInfo info = 3;
DownloadOutput output = 4;
repeated string params = 5;
}
service Ytdlp {
rpc Exec (DownloadRequest) returns (ExecResponse);
rpc ExecPlaylist (DownloadRequest) returns (ExecResponse);
rpc Progress (BaseRequest) returns (DownloadProgress);
rpc Running (Empty) returns (stream ProcessResponse);
rpc Kill (BaseRequest) returns (ExecResponse);
rpc KillAll (Empty) returns (stream ExecResponse);
}

View File

@@ -1,4 +1,4 @@
package dbutils package dbutil
import ( import (
"context" "context"

View File

@@ -1,20 +1,24 @@
package handlers package handlers
import ( import (
"archive/zip"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"io/fs"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"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/utils" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
) )
/* /*
@@ -22,6 +26,20 @@ import (
a entirely self-contained package a entirely self-contained package
*/ */
var (
videoRe = regexp.MustCompile(`(?i)/\.mov|\.mp4|\.webm|\.mvk|/gmi`)
)
func isVideo(d fs.DirEntry) bool {
return videoRe.MatchString(d.Name())
}
func isValidEntry(d fs.DirEntry) bool {
return !strings.HasPrefix(d.Name(), ".") &&
!strings.HasSuffix(d.Name(), ".part") &&
!strings.HasSuffix(d.Name(), ".ytdl")
}
type DirectoryEntry struct { type DirectoryEntry struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
@@ -40,7 +58,7 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
var files []DirectoryEntry var files []DirectoryEntry
for _, d := range dirs { for _, d := range dirs {
if !utils.IsValidEntry(d) { if !isValidEntry(d) {
continue continue
} }
@@ -55,7 +73,7 @@ func walkDir(root string) (*[]DirectoryEntry, error) {
Path: path, Path: path,
Name: d.Name(), Name: d.Name(),
Size: info.Size(), Size: info.Size(),
IsVideo: utils.IsVideo(d), IsVideo: isVideo(d),
IsDirectory: d.IsDir(), IsDirectory: d.IsDir(),
ModTime: info.ModTime(), ModTime: info.ModTime(),
}) })
@@ -174,14 +192,8 @@ func DownloadFile(w http.ResponseWriter, r *http.Request) {
root := config.Instance().DownloadPath root := config.Instance().DownloadPath
if strings.Contains(filepath.Dir(filename), root) { if strings.Contains(filepath.Dir(filename), root) {
w.Header().Add( w.Header().Add("Content-Disposition", "inline; filename=\""+filepath.Base(filename)+"\"")
"Content-Disposition", w.Header().Set("Content-Type", "application/octet-stream")
"inline; filename="+filepath.Base(filename),
)
w.Header().Set(
"Content-Type",
"application/octet-stream",
)
fd, err := os.Open(filename) fd, err := os.Open(filename)
if err != nil { if err != nil {
@@ -194,3 +206,47 @@ 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
}
zipWriter := zip.NewWriter(w)
w.Header().Add(
"Content-Disposition",
"inline; filename=download-archive-"+time.Now().Format(time.RFC3339)+".zip",
)
w.Header().Set("Content-Type", "application/zip")
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
}
if _, err := io.Copy(wr, fd); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := zipWriter.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}

View File

@@ -8,18 +8,19 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
) )
const TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui"
type LoginRequest struct { type LoginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
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
} }
@@ -55,7 +56,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
func Logout(w http.ResponseWriter, r *http.Request) { func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{ cookie := &http.Cookie{
Name: utils.TOKEN_COOKIE_NAME, Name: TOKEN_COOKIE_NAME,
HttpOnly: true, HttpOnly: true,
Secure: false, Secure: false,
Expires: time.Now(), Expires: time.Now(),

View File

@@ -0,0 +1,34 @@
package internal
import (
"container/heap"
)
type LoadBalancer struct {
pool Pool
done chan *Worker
}
func (b *LoadBalancer) Balance(work chan Process) {
for {
select {
case req := <-work:
b.dispatch(req)
case w := <-b.done:
b.completed(w)
}
}
}
func (b *LoadBalancer) dispatch(req Process) {
w := heap.Pop(&b.pool).(*Worker)
w.requests <- req
w.pending++
heap.Push(&b.pool, w)
}
func (b *LoadBalancer) completed(w *Worker) {
w.pending--
heap.Remove(&b.pool, w.index)
heap.Push(&b.pool, w)
}

View File

@@ -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"`

View File

@@ -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"
@@ -24,61 +23,40 @@ func (m *MemoryDB) Get(id string) (*Process, error) {
if !ok { if !ok {
return nil, errors.New("no process found for the given key") return nil, errors.New("no process found for the given key")
} }
return entry.(*Process), nil return entry.(*Process), nil
} }
// Store a pointer of a process and return its id // Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string { func (m *MemoryDB) Set(process *Process) string {
id := uuid.NewString() id := uuid.NewString()
m.table.Store(id, process) m.table.Store(id, process)
process.Id = id process.Id = id
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)
} }
func (m *MemoryDB) Keys() *[]string { func (m *MemoryDB) Keys() *[]string {
running := []string{} var running []string
m.table.Range(func(key, value any) bool { m.table.Range(func(key, value any) bool {
running = append(running, key.(string)) running = append(running, key.(string))
return true return true
}) })
return &running return &running
} }
// Returns a slice of all currently stored processes progess // Returns a slice of all currently stored processes progess
func (m *MemoryDB) All() *[]ProcessResponse { func (m *MemoryDB) All() *[]ProcessResponse {
running := []ProcessResponse{} running := []ProcessResponse{}
m.table.Range(func(key, value any) bool { m.table.Range(func(key, value any) bool {
running = append(running, ProcessResponse{ running = append(running, ProcessResponse{
Id: key.(string), Id: key.(string),
@@ -89,10 +67,11 @@ func (m *MemoryDB) All() *[]ProcessResponse {
}) })
return true return true
}) })
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()
@@ -103,29 +82,25 @@ func (m *MemoryDB) Persist() error {
return errors.Join(errors.New("failed to persist session"), err) return errors.Join(errors.New("failed to persist session"), err)
} }
session := Session{ session := Session{Processes: *running}
Processes: *running,
}
err = gob.NewEncoder(fd).Encode(session) if err := gob.NewEncoder(fd).Encode(session); err != nil {
if err != nil {
return errors.Join(errors.New("failed to persist session"), err) return errors.Join(errors.New("failed to persist session"), err)
} }
return nil return nil
} }
// WIP: Restore a persisted state // Restore a persisted state
func (m *MemoryDB) Restore(logger *slog.Logger) { func (m *MemoryDB) Restore(mq *MessageQueue, 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
} }
@@ -142,8 +117,8 @@ func (m *MemoryDB) Restore(logger *slog.Logger) {
m.table.Store(proc.Id, restored) m.table.Store(proc.Id, restored)
if restored.Progress.Percentage != "-1" { if restored.Progress.Status != StatusCompleted {
go restored.Start() mq.Publish(restored)
} }
} }
} }

View File

@@ -1,63 +1,102 @@
package internal package internal
import ( import (
"context"
"errors"
"log/slog"
evbus "github.com/asaskevich/EventBus"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"golang.org/x/sync/semaphore"
) )
const queueName = "process:pending"
type MessageQueue struct { type MessageQueue struct {
producerCh chan *Process concurrency int
consumerCh chan struct{} eventBus evbus.Bus
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 -1.
// 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, error) {
size := config.Instance().QueueSize qs := config.Instance().QueueSize
if size <= 0 { if qs <= 0 {
panic("invalid queue size") return nil, errors.New("invalid queue size")
} }
return &MessageQueue{ return &MessageQueue{
producerCh: make(chan *Process, size), concurrency: qs,
consumerCh: make(chan struct{}, size), eventBus: evbus.New(),
} logger: l,
}, nil
} }
// 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) {
// needs to have an id set before
p.SetPending() p.SetPending()
go p.SetMetadata()
m.producerCh <- p m.eventBus.Publish(queueName, p)
} }
// Publish a message to the queue and set the task to a peding state. func (m *MessageQueue) SetupConsumers() {
// ENSURE P IS PART OF A PLAYLIST go m.downloadConsumer()
// Needs a further review go m.metadataSubscriber()
func (m *MessageQueue) PublishPlaylistEntry(p *Process) {
m.producerCh <- p
} }
// Setup the consumer listener which subscribes to the changes to the producer // Setup the consumer listener which subscribes to the changes to the producer
// channel and triggers the "download" action. // channel and triggers the "download" action.
func (m *MessageQueue) Subscriber() { func (m *MessageQueue) downloadConsumer() {
for msg := range m.producerCh { sem := semaphore.NewWeighted(int64(m.concurrency))
m.consumerCh <- struct{}{}
go func(p *Process) { m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.TODO(), 1)
defer sem.Release(1)
m.logger.Info("received process from event bus",
slog.String("bus", queueName),
slog.String("consumer", "downloadConsumer"),
slog.String("id", p.getShortId()),
)
p.Start() p.Start()
<-m.consumerCh
}(msg) m.logger.Info("started process",
} slog.String("bus", queueName),
slog.String("id", p.getShortId()),
)
}, false)
} }
// Empties the message queue // Setup the metadata consumer listener which subscribes to the changes to the
func (m *MessageQueue) Empty() { // producer channel and adds metadata to each download.
for range m.producerCh { func (m *MessageQueue) metadataSubscriber() {
<-m.producerCh // How many concurrent metadata fetcher jobs are spawned
} // Since there's ongoing downloads, 1 job at time seems a good compromise
for range m.consumerCh { sem := semaphore.NewWeighted(1)
<-m.consumerCh
m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.TODO(), 1)
defer sem.Release(1)
m.logger.Info("received process from event bus",
slog.String("bus", queueName),
slog.String("consumer", "metadataConsumer"),
slog.String("id", p.getShortId()),
)
if err := p.SetMetadata(); err != nil {
m.logger.Error("failed to retrieve metadata",
slog.String("id", p.getShortId()),
slog.String("err", err.Error()),
)
} }
}, false)
} }

View File

@@ -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 playlist 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
} }
@@ -57,9 +55,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
slog.Int("count", m.Count), slog.Int("count", m.Count),
) )
for i, meta := range m.Entries { for _, meta := range m.Entries {
delta := time.Second.Microseconds() * int64(i+1)
// detect playlist title from metadata since each playlist entry will be // detect playlist title from metadata since each playlist entry will be
// treated as an individual download // treated as an individual download
req.Rename = strings.Replace( req.Rename = strings.Replace(
@@ -72,19 +68,18 @@ 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, Info: meta,
Params: req.Params, Params: req.Params,
Logger: logger,
} }
proc.Info.URL = meta.OriginalURL proc.Info.URL = meta.OriginalURL
proc.Info.CreatedAt = time.Now().Add(time.Duration(delta))
time.Sleep(time.Millisecond)
db.Set(proc) db.Set(proc)
proc.SetPending() mq.Publish(proc)
mq.PublishPlaylistEntry(proc)
} }
err = cmd.Wait() err = cmd.Wait()
@@ -97,6 +92,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
Logger: logger, Logger: logger,
} }
db.Set(proc)
mq.Publish(proc) mq.Publish(proc)
logger.Info("sending new process to message queue", slog.String("url", proc.Url)) logger.Info("sending new process to message queue", slog.String("url", proc.Url))

16
server/internal/pool.go Normal file
View File

@@ -0,0 +1,16 @@
package internal
type Pool []*Worker
func (h Pool) Len() int { return len(h) }
func (h Pool) Less(i, j int) bool { return h[i].index < h[j].index }
func (h Pool) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) }
func (h *Pool) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}

View File

@@ -2,8 +2,11 @@ package internal
import ( import (
"bufio" "bufio"
"bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"regexp" "regexp"
"slices" "slices"
@@ -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.
@@ -91,7 +82,10 @@ func (p *Process) Start() {
buildFilename(&p.Output) buildFilename(&p.Output)
params := []string{ //TODO: it spawn another one yt-dlp process, too slow.
go p.GetFileName(&out)
baseParams := []string{
strings.Split(p.Url, "?list")[0], //no playlist strings.Split(p.Url, "?list")[0], //no playlist
"--newline", "--newline",
"--no-colors", "--no-colors",
@@ -101,12 +95,12 @@ func (p *Process) Start() {
} }
// if user asked to manually override the output path... // if user asked to manually override the output path...
if !(slices.Contains(params, "-P") || slices.Contains(params, "--paths")) { if !(slices.Contains(p.Params, "-P") || slices.Contains(p.Params, "--paths")) {
params = append(params, "-o") p.Params = append(p.Params, "-o")
params = append(params, fmt.Sprintf("%s/%s", out.Path, out.Filename)) p.Params = append(p.Params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
} }
params = append(params, p.Params...) params := append(baseParams, p.Params...)
// ----------------- main block ----------------- // // ----------------- main block ----------------- //
cmd := exec.Command(config.Instance().DownloaderPath, params...) cmd := exec.Command(config.Instance().DownloaderPath, params...)
@@ -120,10 +114,8 @@ func (p *Process) Start() {
) )
panic(err) panic(err)
} }
scan := bufio.NewScanner(r)
err = cmd.Start() if err := cmd.Start(); err != nil {
if err != nil {
p.Logger.Error( p.Logger.Error(
"failed to start yt-dlp process", "failed to start yt-dlp process",
slog.String("err", err.Error()), slog.String("err", err.Error()),
@@ -133,6 +125,8 @@ func (p *Process) Start() {
p.proc = cmd.Process p.proc = cmd.Process
go p.SetMetadata()
// --------------- progress block --------------- // // --------------- progress block --------------- //
var ( var (
sourceChan = make(chan []byte) sourceChan = make(chan []byte)
@@ -142,10 +136,14 @@ 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()
doneChan <- struct{}{} doneChan <- struct{}{}
close(sourceChan) close(sourceChan)
close(doneChan) close(doneChan)
}() }()
@@ -158,21 +156,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 {
return
}
p.Progress = DownloadProgress{ p.Progress = DownloadProgress{
Status: StatusDownloading, Status: StatusDownloading,
Percentage: stdout.Percentage, Percentage: progress.Percentage,
Speed: stdout.Speed, Speed: progress.Speed,
ETA: stdout.Eta, ETA: progress.Eta,
} }
p.Logger.Info("progress", p.Logger.Info("progress",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
slog.String("url", p.Url), slog.String("url", p.Url),
slog.String("percentege", stdout.Percentage), slog.String("percentage", progress.Percentage),
) )
}
}) })
}() }()
@@ -218,11 +219,16 @@ func (p *Process) Kill() error {
} }
// Returns the available format for this URL // Returns the available format for this URL
// TODO: Move out from process.go
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
} }
@@ -269,6 +275,24 @@ 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. // Since video's title isn't available yet, fill in with the URL.
p.Info = DownloadInfo{ p.Info = DownloadInfo{
@@ -285,7 +309,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()),
@@ -298,32 +332,36 @@ 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 { return strings.Split(p.Id, "-")[0] }
return strings.Split(p.Id, "-")[0]
}
func buildFilename(o *DownloadOutput) { func buildFilename(o *DownloadOutput) {
if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") { if o.Filename != "" && strings.Contains(o.Filename, ".%(ext)s") {

15
server/internal/worker.go Normal file
View File

@@ -0,0 +1,15 @@
package internal
type Worker struct {
requests chan Process // downloads to do
pending int // downloads pending
index int // index in the heap
}
func (w *Worker) Work(done chan *Worker) {
for {
req := <-w.requests
req.Start()
done <- w
}
}

View File

@@ -8,6 +8,15 @@ import (
"time" "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 // implements io.Writer interface
type LogRotateWriter struct { type LogRotateWriter struct {
mu sync.Mutex mu sync.Mutex

View File

@@ -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))
) )
@@ -20,9 +31,7 @@ func NewObservableLogger() *ObservableLogger {
} }
func (o *ObservableLogger) Write(p []byte) (n int, err error) { func (o *ObservableLogger) Write(p []byte) (n int, err error) {
go func() {
logsChan <- rxgo.Of(string(p)) logsChan <- rxgo.Of(string(p))
}()
n = len(p) n = len(p)
err = nil err = nil

View File

@@ -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
} }

15
server/rest/common.go Normal file
View File

@@ -0,0 +1,15 @@
package rest
import (
"database/sql"
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
type ContainerArgs struct {
DB *sql.DB
MDB *internal.MemoryDB
MQ *internal.MessageQueue
Logger *slog.Logger
}

View File

@@ -1,24 +1,21 @@
package rest package rest
import ( import (
"database/sql"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
) )
func Container(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) *Handler { func Container(args *ContainerArgs) *Handler {
var ( var (
service = ProvideService(db, mdb, mq) service = ProvideService(args)
handler = ProvideHandler(service) handler = ProvideHandler(service)
) )
return handler return handler
} }
func ApplyRouter(db *sql.DB, mdb *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) { func ApplyRouter(args *ContainerArgs) func(chi.Router) {
h := Container(db, mdb, mq) h := Container(args)
return func(r chi.Router) { return func(r chi.Router) {
if config.Instance().RequireAuth { if config.Instance().RequireAuth {

View File

@@ -22,7 +22,7 @@ func (h *Handler) Exec() http.HandlerFunc {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
req := internal.DownloadRequest{} var req internal.DownloadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

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

View File

@@ -54,7 +54,6 @@ func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) er
} }
*result = "" *result = ""
return nil return nil
} }
@@ -64,14 +63,17 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
if err != nil { if err != nil {
return err return err
} }
*progress = proc.Progress *progress = proc.Progress
return nil return nil
} }
// Progess retrieves available format for a given resource // Progess retrieves available format for a given resource
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error { func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
var err error var (
p := internal.Process{Url: args.URL, Logger: s.logger} err error
p = internal.Process{Url: args.URL, Logger: s.logger}
)
*meta, err = p.GetFormatsSync() *meta, err = p.GetFormatsSync()
return err return err
} }
@@ -91,11 +93,12 @@ func (s *Service) Running(args NoArgs, running *Running) error {
// Kill kills a process given its id and remove it from the memoryDB // Kill kills a process given its id and remove it from the memoryDB
func (s *Service) Kill(args string, killed *string) error { func (s *Service) Kill(args string, killed *string) error {
s.logger.Info("Trying killing process with id", slog.String("id", args)) s.logger.Info("Trying killing process with id", slog.String("id", args))
proc, err := s.db.Get(args)
proc, err := s.db.Get(args)
if err != nil { if err != nil {
return err return err
} }
if proc != nil { if proc != nil {
err = proc.Kill() err = proc.Kill()
s.db.Delete(proc.Id) s.db.Delete(proc.Id)
@@ -109,19 +112,24 @@ func (s *Service) Kill(args string, killed *string) error {
// the memory db // the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error { func (s *Service) KillAll(args NoArgs, killed *string) error {
s.logger.Info("Killing all spawned processes") s.logger.Info("Killing all spawned processes")
keys := s.db.Keys()
var err error var (
keys = s.db.Keys()
err error
)
for _, key := range *keys { for _, key := range *keys {
proc, err := s.db.Get(key) proc, err := s.db.Get(key)
if err != nil { if err != nil {
return err return err
} }
if proc != nil { if proc != nil {
proc.Kill() proc.Kill()
s.db.Delete(proc.Id) s.db.Delete(proc.Id)
} }
} }
s.mq.Empty()
return err return err
} }
@@ -135,6 +143,10 @@ func (s *Service) Clear(args string, killed *string) error {
// FreeSpace gets the available from package sys util // FreeSpace gets the available from package sys util
func (s *Service) FreeSpace(args NoArgs, free *uint64) error { func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
freeSpace, err := sys.FreeSpace() freeSpace, err := sys.FreeSpace()
if err != nil {
return err
}
*free = freeSpace *free = freeSpace
return err return err
} }
@@ -142,20 +154,31 @@ 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 *[]string) error { func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
dfsTree, err := sys.DirectoryTree() dfsTree, err := sys.DirectoryTree()
if err != nil {
*tree = nil
return err
}
if dfsTree != nil { if dfsTree != nil {
*tree = *dfsTree *tree = *dfsTree
} }
return err
return nil
} }
// Updates the yt-dlp binary using its builtin function // Updates the yt-dlp binary using its builtin function
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error { func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
s.logger.Info("Updating yt-dlp executable to the latest release") s.logger.Info("Updating yt-dlp executable to the latest release")
err := updater.UpdateExecutable()
if err != nil { if err := updater.UpdateExecutable(); err != nil {
*updated = true s.logger.Error("Failed updating yt-dlp")
return err
}
*updated = false *updated = false
return err return err
}
*updated = true
s.logger.Info("Succesfully updated yt-dlp")
return nil
} }

View File

@@ -19,7 +19,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"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/dbutils" "github.com/marcopeocchi/yt-dlp-web-ui/server/dbutil"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers" "github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging" "github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
@@ -33,14 +33,16 @@ import (
type RunConfig struct { type RunConfig struct {
Host string Host string
Port int Port int
App fs.FS
DBPath string DBPath string
LogFile string LogFile string
FileLogging bool FileLogging bool
App fs.FS
Swagger fs.FS
} }
type serverConfig struct { type serverConfig struct {
frontend fs.FS frontend fs.FS
swagger fs.FS
logger *slog.Logger logger *slog.Logger
host string host string
port int port int
@@ -77,23 +79,26 @@ func RunBlocking(cfg *RunConfig) {
slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}), slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}),
) )
mdb.Restore(logger)
db, err := sql.Open("sqlite", cfg.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()))
} }
err = dbutils.AutoMigrate(context.Background(), db) if err := dbutil.AutoMigrate(context.Background(), db); err != nil {
if err != nil {
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, err := internal.NewMessageQueue(logger)
go mq.Subscriber() if err != nil {
panic(err)
}
mq.SetupConsumers()
go mdb.Restore(mq, logger)
srv := newServer(serverConfig{ srv := newServer(serverConfig{
frontend: cfg.App, frontend: cfg.App,
swagger: cfg.Swagger,
logger: logger, logger: logger,
host: cfg.Host, host: cfg.Host,
port: cfg.Port, port: cfg.Port,
@@ -153,6 +158,7 @@ func newServer(c serverConfig) *http.Server {
// r.Use(middleware.Logger) // r.Use(middleware.Logger)
r.Mount("/", http.FileServer(http.FS(c.frontend))) r.Mount("/", http.FileServer(http.FS(c.frontend)))
r.Mount("/openapi", http.FileServer(http.FS(c.swagger)))
// Archive routes // Archive routes
r.Route("/archive", func(r chi.Router) { r.Route("/archive", func(r chi.Router) {
@@ -163,6 +169,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
@@ -175,7 +182,12 @@ func newServer(c serverConfig) *http.Server {
r.Route("/rpc", ytdlpRPC.ApplyRouter()) r.Route("/rpc", ytdlpRPC.ApplyRouter())
// REST API handlers // REST API handlers
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mdb, c.mq)) r.Route("/api/v1", rest.ApplyRouter(&rest.ContainerArgs{
DB: c.db,
MDB: c.mdb,
MQ: c.mq,
Logger: c.logger,
}))
// Logging // Logging
r.Route("/log", logging.ApplyRouter()) r.Route("/log", logging.ApplyRouter())

View File

@@ -26,10 +26,12 @@ func DirectoryTree() (*[]string, error) {
children []Node children []Node
} }
rootPath := config.Instance().DownloadPath var (
rootPath = config.Instance().DownloadPath
stack := internal.NewStack[Node]() stack = internal.NewStack[Node]()
flattened := make([]string, 0) flattened = make([]string, 0)
)
stack.Push(Node{path: rootPath}) stack.Push(Node{path: rootPath})
@@ -37,14 +39,16 @@ func DirectoryTree() (*[]string, error) {
for stack.IsNotEmpty() { for stack.IsNotEmpty() {
current := stack.Pop().Value 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 := Node{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)

View File

@@ -1,5 +0,0 @@
package utils
const (
TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui"
)

View File

@@ -1,21 +0,0 @@
package utils
import (
"io/fs"
"regexp"
"strings"
)
var (
videoRe = regexp.MustCompile(`(?i)/\.mov|\.mp4|\.webm|\.mvk|/gmi`)
)
func IsVideo(d fs.DirEntry) bool {
return videoRe.MatchString(d.Name())
}
func IsValidEntry(d fs.DirEntry) bool {
return !strings.HasPrefix(d.Name(), ".") &&
!strings.HasSuffix(d.Name(), ".part") &&
!strings.HasSuffix(d.Name(), ".ytdl")
}