Compare commits

...

23 Commits

Author SHA1 Message Date
d4305bb2f8 re-enabled armv6 builds
code refactoring
2024-03-18 10:27:40 +01:00
3f836d0fa6 added some comments on the server side 2024-03-18 10:19:39 +01:00
b45107c94b Fixed observable logger, added build stage for frontend
dependencies update

closes #131
2024-03-14 11:59:33 +01:00
0x6d61726b
9cf1a3bc7e removed duplicate/unused file (used in 'frontend/src/assets/', relates to #139) (#140) 2024-03-04 14:40:06 +01:00
df3522fcb3 fixed favicon not showing 2024-03-03 22:32:44 +01:00
e2c27c3857 Added favicon
Closes #139
2024-03-03 20:33:35 +01:00
0x6d61726b
51bcd82ea7 Fixed human-readable file size representation (#137)
(as it follows units of IEC 60027-2 A.2 )
2024-03-03 15:48:56 +01:00
0x6d61726b
f763b9657f Extended config.yml example (#136) 2024-03-03 15:47:52 +01:00
Marco
b9b0fde520 Update README.md 2024-02-05 13:40:50 +01:00
deluxghost
6e123c319f i18n: chinese (#133) 2024-02-05 08:42:26 +01:00
Calm Zhu
de975f758f bugfix: port config in config file not work (#132)
* Update main.go

* update

* Update main.go

fix lint
2024-01-31 19:37:50 +01:00
d3371ed64c handle -P or --paths yt-dlp flag 2024-01-26 11:36:39 +01:00
15766bd016 handle -P param 2024-01-26 11:33:22 +01:00
c78b3ae174 introduced millionjs compiler 2024-01-16 13:24:08 +01:00
3d9a7e9810 fixed nil pointer dereferece
closes #128
2024-01-12 10:55:29 +01:00
Marco
8aeffb8d9f Update README.md 2024-01-10 22:43:41 +01:00
Marco
7904904a37 Update README.md 2024-01-09 14:49:01 +01:00
Marco
6aa2d41988 Logging in webUI, Archive view refactor (#127)
* test logging

* test impl for logging

* implemented "live logging", restyle templates dropdown

* moved extract audio to downloadDialog, fixed labels

* code refactoring

* buffering logs
2024-01-09 14:29:18 +01:00
de1d9e6a3c added german, code refactoring 2023-12-30 11:06:00 +01:00
Baipyrus
1b46f0dd03 added german language data to frontend (#120) 2023-12-30 10:20:43 +01:00
Marco
8870602268 Update README.md 2023-12-28 11:15:52 +01:00
Marco
15d9d261a3 Update README.md 2023-12-27 15:47:03 +01:00
Marco
f3302c17cc 115 download button (#119)
* code refactoring, added file download

* code refactoring
2023-12-27 15:38:02 +01:00
49 changed files with 1775 additions and 3848 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ session.dat
config.yml config.yml
cookies.txt cookies.txt
__debug* __debug*
ui/

View File

@@ -1,18 +1,30 @@
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
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 alpine:edge FROM alpine:edge
VOLUME /downloads /config VOLUME /downloads /config

View File

@@ -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-armv7 main.go
CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv6 main.go
clean: clean:
rm -rf build rm -rf build

View File

@@ -1,3 +1,13 @@
> [!IMPORTANT]
> I'm looking for a co-mantainer.
> Lately I'm not feeling well both physically and mentally.
---
> [!IMPORTANT]
> Major frontend refactoring in progress.
> I won't add features or fix minor issues until completition.
---
# yt-dlp Web UI # yt-dlp Web UI
A not so terrible web ui for yt-dlp. A not so terrible web ui for yt-dlp.
@@ -17,9 +27,8 @@ docker pull marcobaobao/yt-dlp-webui
```sh ```sh
# latest dev # latest dev
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
# latest stable version
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
``` ```
![output](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/82bcecaf-4ced-441f-9384-105653abfae4)
![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/a32fbdaa-b033-4aed-b914-a66701ace0ce) ![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/a32fbdaa-b033-4aed-b914-a66701ace0ce)
![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/782c559a-f552-40be-a6fd-10e22f38e85d) ![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/782c559a-f552-40be-a6fd-10e22f38e85d)
@@ -75,20 +84,10 @@ The currently avaible settings are:
## Format selection ## Format selection
![fs1](https://i.ibb.co/8dgS6ym/image.png)
This feature is disabled by default as this intended to be used to retrieve the best quality automatically. This feature is disabled by default as this intended to be used to retrieve the best quality automatically.
To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag! To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag!
Future releases will have:
- ~~Multi download~~ *done*
- ~~Exctract audio~~ *done*
- ~~Format selection~~ *done*
- ~~Download archive~~ *done*
- ~~ARM Build~~ *done available through ghcr.io*
- Playlist support
## Troubleshooting ## Troubleshooting
- **It says that it isn't connected/ip in the header is not defined.** - **It says that it isn't connected/ip in the header is not defined.**
- You must set the server ip address in the settings section (gear icon). - You must set the server ip address in the settings section (gear icon).
@@ -97,7 +96,6 @@ Future releases will have:
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation ## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
```sh ```sh
# recomended for ARM and x86 devices
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
``` ```
@@ -187,16 +185,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
@@ -250,12 +263,5 @@ Just as an overview, these are the available methods:
For more information open an issue on GitHub and I will provide more info ASAP. For more information open an issue on GitHub and I will provide more info ASAP.
## FAQ
- **Will it availabe for Raspberry Pi/ generic ARM devices?**
- Yes, it's cross platform :)
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
- **Why the docker image is so heavy?**
- Originally it was 1.8GB circa, now it has been slimmed to ~340MB compressed. This is due to the fact that it encapsule a basic Alpine linux image + FFmpeg + Node.js + Python3 + yt-dlp.
- **Update**: Since Golang migration and Multi-Stage builds the Docker image is now 75MB circa. A reduction of over 400% in size :D.
## What yt-dlp-webui is not ## What yt-dlp-webui is not
`yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS. `yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS.

View File

@@ -5,6 +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="./src/assets/favicon.ico">
<title>yt-dlp Web UI</title> <title>yt-dlp Web UI</title>
</head> </head>

File diff suppressed because it is too large Load Diff

View File

@@ -10,27 +10,29 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.6", "@fontsource/roboto": "^5.0.8",
"@mui/icons-material": "^5.11.16", "@fontsource/roboto-mono": "^5.0.16",
"@mui/material": "^5.13.5", "@mui/icons-material": "^5.15.4",
"fp-ts": "^2.16.1", "@mui/material": "^5.15.4",
"fp-ts": "^2.16.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.17.0", "react-router-dom": "^6.21.2",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"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.8.7", "@types/node": "^20.11.4",
"@types/react": "^18.2.29", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.14", "@types/react-dom": "^18.2.18",
"@types/react-helmet": "^6.1.8", "@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.4.0", "@vitejs/plugin-react-swc": "^3.5.0",
"typescript": "^5.3.2", "typescript": "^5.3.3",
"vite": "^4.5.0" "vite": "^5.1.6",
"million": "^3.0.6"
} }
} }

1214
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ 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)
@@ -138,6 +139,19 @@ export default function Layout() {
<ListItemText primary={i18n.t('archiveButtonLabel')} /> <ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton> </ListItemButton>
</Link> </Link>
<Link to={'/log'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<TerminalIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('logsTitle')} />
</ListItemButton>
</Link>
<Link to={'/settings'} style={ <Link to={'/settings'} style={
{ {
textDecoration: 'none', textDecoration: 'none',

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -47,6 +47,57 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
german:
urlInput: Video URL
statusTitle: Status
statusReady: Bereit
selectFormatButton: Format auswählen
startButton: Start
abortAllButton: Alle Abbrechen
updateBinButton: yt-dlp Binärdatei aktualisieren
darkThemeButton: Dunkel Modus
lightThemeButton: Hell Modus
settingsAnchor: Einstellungen
serverAddressTitle: Server Adresse
serverPortTitle: Port
extractAudioCheckbox: Audio extrahieren
noMTimeCheckbox: Datei-Änderungszeitpunkt nicht festlegen
bgReminder: Sobald Sie diese Seite schließen, wird der Download im Hintergrund fortgesetzt.
toastConnected: 'Verbunden mit '
toastUpdated: yt-dlp Binärdatei aktualisiert!
formatSelectionEnabler: Video/Audio Format auswählbar
themeSelect: 'Modus'
languageSelect: 'Sprache'
overridesAnchor: Überschreibungen
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
customFilename: Custom filemame (leave blank to use default)
customPath: Benutzerdefinierter Pfad
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
customArgsInput: Benutzerdefinierte yt-dlp Argumente
rpcConnErr: Fehler beim Verbinden mit RPC Server
splashText: Keine aktiven Downloads
archiveTitle: Archiv
clipboardAction: URL in Zwischenablage kopiert
playlistCheckbox: Playlist herunterladen (es wird einige Zeit dauern, nach dem Absenden können Sie dieses Fenster schließen)
restartAppMessage: Erfordert ein Neuladen der Seite, um wirksam zu werden
servedFromReverseProxyCheckbox: Ist hinter einem Reverse Proxy Unterordner
newDownloadButton: Neuer Download
homeButtonLabel: Home
archiveButtonLabel: Archiv
settingsButtonLabel: Einstellungen
rpcAuthenticationLabel: RPC Authentifizierung
themeTogglerLabel: Modus Umschalter
loadingLabel: Lädt...
appTitle: App Titel
savedTemplates: Gespeicherte Vorlage
templatesEditor: Vorlagen Bearbeiter
templatesEditorNameLabel: Vorlagen Name
templatesEditorContentLabel: Vorlagen Inhalt
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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
@@ -96,6 +147,8 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
italian: italian:
urlInput: URL Video urlInput: URL Video
statusTitle: Stato statusTitle: Stato
@@ -129,7 +182,7 @@ languages:
clipboardAction: URL copiato negli appunti clipboardAction: URL copiato negli appunti
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro) playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder servedFromReverseProxyCheckbox: Is behind a reverse proxy
newDownloadButton: Nuovo download newDownloadButton: Nuovo download
homeButtonLabel: Home homeButtonLabel: Home
archiveButtonLabel: Archive archiveButtonLabel: Archive
@@ -142,8 +195,10 @@ languages:
templatesEditor: Editor template templatesEditor: Editor template
templatesEditorNameLabel: Nome template templatesEditorNameLabel: Nome template
templatesEditorContentLabel: Contentunto template templatesEditorContentLabel: Contentunto template
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
chinese: chinese:
urlInput: YouTube 或其他受支持服务的视频网址 urlInput: 视频 URL
statusTitle: 状态 statusTitle: 状态
statusReady: 就绪 statusReady: 就绪
selectFormatButton: 选择格式 selectFormatButton: 选择格式
@@ -177,18 +232,20 @@ languages:
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待) playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
restartAppMessage: 需要刷新页面才能生效 restartAppMessage: 需要刷新页面才能生效
servedFromReverseProxyCheckbox: 处于反向代理的子目录后 servedFromReverseProxyCheckbox: 处于反向代理的子目录后
newDownloadButton: New download newDownloadButton: 新下载
homeButtonLabel: Home homeButtonLabel: 主页
archiveButtonLabel: Archive archiveButtonLabel: 归档
settingsButtonLabel: Settings settingsButtonLabel: 设置
rpcAuthenticationLabel: RPC authentication rpcAuthenticationLabel: RPC 身份验证
themeTogglerLabel: Theme toggler themeTogglerLabel: 主题切换
loadingLabel: Loading... loadingLabel: 正在加载…
appTitle: App 标题 appTitle: App 标题
savedTemplates: Saved templates savedTemplates: 保存模板
templatesEditor: Templates editor templatesEditor: 模板编辑器
templatesEditorNameLabel: Template name templatesEditorNameLabel: 模板名称
templatesEditorContentLabel: Template content templatesEditorContentLabel: 模板内容
logsTitle: '日志'
awaitingLogs: '正在等待日志…'
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -234,6 +291,8 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
russian: russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус statusTitle: Статус
@@ -279,6 +338,8 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -324,6 +385,8 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -370,6 +433,8 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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
@@ -415,6 +480,8 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
ukrainian: ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус statusTitle: Статус
@@ -460,6 +527,8 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
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
@@ -505,3 +574,5 @@ languages:
templatesEditor: Templates editor templatesEditor: Templates editor
templatesEditorNameLabel: Template name templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'

View File

@@ -12,6 +12,7 @@ export const languages = [
'catalan', 'catalan',
'ukrainian', 'ukrainian',
'polish', 'polish',
'german'
] as const ] as const
export type Language = (typeof languages)[number] export type Language = (typeof languages)[number]

View File

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

View File

@@ -17,7 +17,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useCallback } from 'react' import { useCallback } from 'react'
import { RPCResult } from '../types' import { RPCResult } from '../types'
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils' import { ellipsis, formatSpeedMiB, mapProcessStatus, formatSize } from '../utils'
type Props = { type Props = {
download: RPCResult download: RPCResult
@@ -86,7 +86,7 @@ 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>

View File

@@ -32,8 +32,8 @@ import {
useTransition useTransition
} from 'react' } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate' import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate'
import { settingsState } from '../atoms/settings' import { latestCliArgumentsState, settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status' import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
@@ -63,6 +63,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const isConnected = useRecoilValue(connectedState) const isConnected = useRecoilValue(connectedState)
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState) const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
const downloadTemplate = useRecoilValue(downloadTemplateState) const downloadTemplate = useRecoilValue(downloadTemplateState)
const savedTemplates = useRecoilValue(savedTemplatesState)
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>() const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState('') const [pickedVideoFormat, setPickedVideoFormat] = useState('')
@@ -70,6 +71,8 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const [pickedBestFormat, setPickedBestFormat] = useState('') const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useRecoilState(customArgsState) const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
const [, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [downloadPath, setDownloadPath] = useState('') const [downloadPath, setDownloadPath] = useState('')
const [filenameTemplate, setFilenameTemplate] = useRecoilState( const [filenameTemplate, setFilenameTemplate] = useRecoilState(
@@ -81,7 +84,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const [isPlaylist, setIsPlaylist] = useState(false) const [isPlaylist, setIsPlaylist] = useState(false)
const cliArgs = useMemo(() => const argsBuilder = useMemo(() =>
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs] new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
) )
@@ -108,7 +111,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
client.download({ client.download({
url: immediate || url || workingUrl, url: immediate || url || workingUrl,
args: `${cliArgs.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`, args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
pathOverride: downloadPath ?? '', pathOverride: downloadPath ?? '',
renameTo: settings.fileRenaming ? filenameTemplate : '', renameTo: settings.fileRenaming ? filenameTemplate : '',
playlist: isPlaylist, playlist: isPlaylist,
@@ -313,9 +316,31 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
} }
</Grid> </Grid>
<Suspense> <Suspense>
<ExtraDownloadOptions /> {savedTemplates.length > 0 && <ExtraDownloadOptions />}
</Suspense> </Suspense>
<Grid container spacing={1} pt={2} justifyContent="space-between"> <Grid container spacing={1} pt={2} justifyContent="space-between">
<Grid item>
<Grid item>
<FormControlLabel
control={<Checkbox onChange={() => setIsPlaylist(state => !state)} />}
checked={isPlaylist}
label={i18n.t('playlistCheckbox')}
/>
</Grid>
<Grid item>
<FormControlLabel
control={
<Checkbox
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
/>
}
checked={argsBuilder.extractAudio}
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
disabled={settings.formatSelection}
label={i18n.t('extractAudioCheckbox')}
/>
</Grid>
</Grid>
<Grid item> <Grid item>
<Button <Button
variant="contained" variant="contained"
@@ -332,13 +357,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
} }
</Button> </Button>
</Grid> </Grid>
<Grid item>
<FormControlLabel
control={<Checkbox onChange={() => setIsPlaylist(state => !state)} />}
checked={isPlaylist}
label={i18n.t('playlistCheckbox')}
/>
</Grid>
</Grid> </Grid>
</Paper> </Paper>
</Grid> </Grid>

View File

@@ -14,7 +14,7 @@ import {
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads' import { activeDownloadsState } from '../atoms/downloads'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { ellipsis, formatSpeedMiB, roundMiB } from "../utils" import { ellipsis, formatSpeedMiB, formatSize } from "../utils"
const DownloadsListView: React.FC = () => { const DownloadsListView: React.FC = () => {
@@ -27,9 +27,14 @@ const DownloadsListView: React.FC = () => {
return ( return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}> <Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
<Grid item xs={12}> <Grid item xs={12}>
<TableContainer component={Paper} sx={{ minHeight: '100%' }} elevation={2}> <TableContainer
component={Paper}
sx={{ minHeight: '100%' }}
elevation={2}
hidden={downloads.length === 0}
>
<Table> <Table>
<TableHead hidden={downloads.length === 0}> <TableHead>
<TableRow> <TableRow>
<TableCell> <TableCell>
<Typography fontWeight={500} fontSize={15}>Title</Typography> <Typography fontWeight={500} fontSize={15}>Title</Typography>
@@ -69,7 +74,7 @@ const DownloadsListView: React.FC = () => {
/> />
</TableCell> </TableCell>
<TableCell>{formatSpeedMiB(download.progress.speed)}</TableCell> <TableCell>{formatSpeedMiB(download.progress.speed)}</TableCell>
<TableCell>{roundMiB(download.info.filesize_approx ?? 0)}</TableCell> <TableCell>{formatSize(download.info.filesize_approx ?? 0)}</TableCell>
<TableCell> <TableCell>
<Button <Button
variant="contained" variant="contained"

View File

@@ -1,4 +1,4 @@
import { Autocomplete, Box, TextField } from '@mui/material' import { Autocomplete, Box, TextField, Typography } from '@mui/material'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate' import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
@@ -22,9 +22,23 @@ const ExtraDownloadOptions: React.FC = () => {
renderOption={(props, option) => ( renderOption={(props, option) => (
<Box <Box
component="li" component="li"
sx={{ mr: 2, flexShrink: 0 }} {...props}
{...props}> >
{option.label} <Box sx={{
display: 'flex',
flexDirection: 'column',
alignContent: 'flex-start',
justifyContent: 'flex-start',
alignItems: 'flex-start',
width: '100%'
}}>
<Typography>
{option.label}
</Typography>
<Typography variant="subtitle2" color="primary">
{option.content}
</Typography>
</Box>
</Box> </Box>
)} )}
sx={{ width: '100%', mt: 2 }} sx={{ width: '100%', mt: 2 }}

View File

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

View File

@@ -0,0 +1,87 @@
import { Box, Container, Paper, Typography } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
const token = localStorage.getItem('token')
const LogTerminal: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const { i18n } = useI18n()
const [logBuffer, setLogBuffer] = useState<string[]>([])
const boxRef = useRef<HTMLDivElement>(null)
const eventSource = useMemo(
() => new EventSource(`${serverAddr}/log/sse?token=${token}`),
[serverAddr]
)
useEffect(() => {
eventSource.addEventListener('log', event => {
const msg: string[] = JSON.parse(event.data)
setLogBuffer(buff => [...buff, ...msg].slice(-100))
boxRef.current?.scrollTo(0, boxRef.current.scrollHeight)
})
// TODO: in dev mode it breaks sse
return () => eventSource.close()
}, [eventSource])
const logEntryStyle = (data: string) => {
const sx = {}
if (data.includes("level=ERROR")) {
return { ...sx, color: 'red' }
}
if (data.includes("level=WARN")) {
return { ...sx, color: 'orange' }
}
return sx
}
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Paper
sx={{
p: 2.5,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography py={1} variant="h5" color="primary">
{i18n.t('logsTitle')}
</Typography>
<Box
ref={boxRef}
sx={{
fontFamily: 'Roboto Mono',
height: '75.5vh',
overflowY: 'auto',
overflowX: 'auto',
fontSize: '13.5px',
fontWeight: '600',
backgroundColor: 'black',
color: 'white',
padding: '0.5rem',
borderRadius: '0.25rem'
}}
>
{logBuffer.length === 0 && <Box >{i18n.t('awaitingLogs')}</Box>}
{logBuffer.map((log, idx) => (
<Box key={idx} sx={logEntryStyle(log)}>
{log}
</Box>
))}
</Box>
</Paper>
</Container >
)
}
export default LogTerminal

View File

@@ -6,6 +6,9 @@ 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'
const root = createRoot(document.getElementById('root')!) const root = createRoot(document.getElementById('root')!)

View File

@@ -15,11 +15,13 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
} }
} }
if (opt?.headers) { const res = await fetch(url, {
opt.headers = { ...opt.headers, 'X-Authentication': jwt ?? '' } ...opt,
} headers: {
...opt?.headers,
const res = await fetch(url, opt) 'X-Authentication': jwt ?? ''
}
})
if (!res.ok) { if (!res.ok) {
throw await res.text() throw await res.text()

View File

@@ -2,6 +2,7 @@ import { CircularProgress } from '@mui/material'
import { Suspense, lazy } from 'react' import { Suspense, lazy } from 'react'
import { createHashRouter } from 'react-router-dom' import { createHashRouter } from 'react-router-dom'
import Layout from './Layout' import Layout from './Layout'
import Terminal from './views/Terminal'
const Home = lazy(() => import('./views/Home')) const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login')) const Login = lazy(() => import('./views/Login'))
@@ -36,6 +37,14 @@ export const router = createHashRouter([
</Suspense > </Suspense >
) )
}, },
{
path: '/log',
element: (
<Suspense fallback={<CircularProgress />}>
<Terminal />
</Suspense >
)
},
{ {
path: '/archive', path: '/archive',
element: ( element: (

View File

@@ -42,14 +42,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()

View File

@@ -9,12 +9,13 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
IconButton,
List, List,
ListItem, ListItem,
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
MenuItem,
MenuList,
Paper, Paper,
SpeedDial, SpeedDial,
SpeedDialAction, SpeedDialAction,
@@ -27,6 +28,7 @@ import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile' import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import VideoFileIcon from '@mui/icons-material/VideoFile' import VideoFileIcon from '@mui/icons-material/VideoFile'
import DownloadIcon from '@mui/icons-material/Download'
import { matchW } from 'fp-ts/lib/TaskEither' import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { useEffect, useMemo, useState, useTransition } from 'react' import { useEffect, useMemo, useState, useTransition } from 'react'
@@ -38,12 +40,14 @@ import { useObservable } from '../hooks/observable'
import { useToast } from '../hooks/toast' 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 { DeleteRequest, DirectoryEntry } from '../types' import { DirectoryEntry } from '../types'
import { base64URLEncode, roundMiB } from '../utils' import { base64URLEncode, formatSize } from '../utils'
import DownloadIcon from '@mui/icons-material/Download'
export default function Downloaded() { export default function Downloaded() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
const [showMenu, setShowMenu] = useState(false)
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
const serverAddr = useRecoilValue(serverURL) const serverAddr = useRecoilValue(serverURL)
const navigate = useNavigate() const navigate = useNavigate()
@@ -122,7 +126,7 @@ export default function Downloaded() {
combineLatestWith(selected$), combineLatestWith(selected$),
map(([data, selected]) => data.map(x => ({ map(([data, selected]) => data.map(x => ({
...x, ...x,
selected: selected.includes(x.path) selected: selected.includes(x.name)
}))), }))),
share() share()
), []) ), [])
@@ -135,19 +139,24 @@ export default function Downloaded() {
: selected$.next([...selected$.value, name]) : selected$.next([...selected$.value, name])
} }
const deleteFile = (entry: DirectoryEntry) => pipe(
ffetch(`${serverAddr}/archive/delete`, {
method: 'POST',
body: JSON.stringify({
path: entry.path,
shaSum: entry.shaSum,
})
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => fetcher()
)
)()
const deleteSelected = () => { const deleteSelected = () => {
Promise.all(selectable Promise.all(selectable
.filter(entry => entry.selected) .filter(entry => entry.selected)
.map(entry => fetch(`${serverAddr}/archive/delete`, { .map(deleteFile)
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: entry.path,
shaSum: entry.shaSum,
} as DeleteRequest)
}))
).then(fetcher) ).then(fetcher)
} }
@@ -172,18 +181,42 @@ export default function Downloaded() {
}) })
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container
maxWidth="lg"
sx={{ mt: 4, mb: 4, height: '100%' }}
onClick={() => setShowMenu(false)}
>
<IconMenu
posX={menuPos.x}
posY={menuPos.y}
hide={!showMenu}
onDownload={() => {
if (currentFile) {
downloadFile(currentFile?.path)
setCurrentFile(undefined)
}
}}
onDelete={() => {
if (currentFile) {
deleteFile(currentFile)
setCurrentFile(undefined)
}
}}
/>
<Backdrop <Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }} sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!(files$.observed) || isPending} open={!(files$.observed) || isPending}
> >
<CircularProgress color="primary" /> <CircularProgress color="primary" />
</Backdrop> </Backdrop>
<Paper sx={{ <Paper
p: 2, sx={{
display: 'flex', p: 2,
flexDirection: 'column', display: 'flex',
}}> flexDirection: 'column',
}}
onClick={() => setShowMenu(false)}
>
<Typography py={1} variant="h5" color="primary"> <Typography py={1} variant="h5" color="primary">
{i18n.t('archiveTitle')} {i18n.t('archiveTitle')}
</Typography> </Typography>
@@ -191,6 +224,12 @@ export default function Downloaded() {
{selectable.length === 0 && 'No files found'} {selectable.length === 0 && 'No files found'}
{selectable.map((file, idx) => ( {selectable.map((file, idx) => (
<ListItem <ListItem
onContextMenu={(e) => {
e.preventDefault()
setCurrentFile(file)
setMenuPos({ x: e.clientX, y: e.clientY })
setShowMenu(true)
}}
key={idx} key={idx}
secondaryAction={ secondaryAction={
<div> <div>
@@ -198,21 +237,14 @@ export default function Downloaded() {
variant="caption" variant="caption"
component="span" component="span"
> >
{roundMiB(file.size)} {formatSize(file.size)}
</Typography> </Typography>
} }
{!file.isDirectory && <> {!file.isDirectory && <>
<IconButton
size='small'
onClick={() => downloadFile(file.path)}
sx={{ marginLeft: 1.5 }}
>
<DownloadIcon />
</IconButton>
<Checkbox <Checkbox
edge="end" edge="end"
checked={file.selected} checked={file.selected}
onChange={() => addSelected(file.path)} onChange={() => addSelected(file.name)}
/> />
</>} </>}
</div> </div>
@@ -275,11 +307,15 @@ export default function Downloaded() {
</ul> </ul>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button> <Button onClick={() => setOpenDialog(false)}>
<Button onClick={() => { Cancel
deleteSelected() </Button>
setOpenDialog(false) <Button
}} autoFocus onClick={() => {
deleteSelected()
setOpenDialog(false)
}}
autoFocus
> >
Ok Ok
</Button> </Button>
@@ -288,3 +324,42 @@ export default function Downloaded() {
</Container> </Container>
) )
} }
const IconMenu: React.FC<{
posX: number
posY: number
hide: boolean
onDownload: () => void
onDelete: () => void
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
return (
<Paper sx={{
width: 320,
maxWidth: '100%',
position: 'absolute',
top: posY,
left: posX,
display: hide ? 'none' : 'block',
zIndex: (theme) => theme.zIndex.drawer + 1,
}}>
<MenuList>
<MenuItem onClick={onDownload}>
<ListItemIcon>
<DownloadIcon fontSize="small" />
</ListItemIcon>
<ListItemText>
Download
</ListItemText>
</MenuItem>
<MenuItem onClick={onDelete}>
<ListItemIcon>
<DeleteForeverIcon fontSize="small" />
</ListItemIcon>
<ListItemText>
Delete
</ListItemText>
</MenuItem>
</MenuList>
</Paper>
)
}

View File

@@ -0,0 +1,9 @@
import LogTerminal from '../components/LogTerminal'
const Terminal: React.FC = () => {
return (
<LogTerminal />
)
}
export default Terminal

View File

@@ -1,10 +1,12 @@
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import million from 'million/compiler'
import ViteYaml from '@modyfi/vite-plugin-yaml' import ViteYaml from '@modyfi/vite-plugin-yaml'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
export default defineConfig(() => { export default defineConfig(() => {
return { return {
plugins: [ plugins: [
million.vite({ auto: true }),
react(), react(),
ViteYaml(), ViteYaml(),
], ],

9
go.mod
View File

@@ -9,19 +9,28 @@ require (
github.com/google/uuid v1.5.0 github.com/google/uuid v1.5.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/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
github.com/reactivex/rxgo/v2 v2.5.0
golang.org/x/sys v0.15.0 golang.org/x/sys v0.15.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.28.0
) )
require ( require (
github.com/cenkalti/backoff/v4 v4.0.0 // indirect
github.com/davecgh/go-spew v1.1.0 // 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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // 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/testify v1.4.0 // indirect
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 // indirect
golang.org/x/mod v0.14.0 // indirect golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect golang.org/x/net v0.19.0 // indirect
golang.org/x/tools v0.16.1 // indirect golang.org/x/tools v0.16.1 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
lukechampine.com/uint128 v1.3.0 // indirect lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect modernc.org/ccgo/v3 v3.16.15 // indirect

40
go.sum
View File

@@ -1,5 +1,11 @@
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/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/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/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.0.11/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=
@@ -14,26 +20,58 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
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/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/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 h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
github.com/mattn/go-isatty v0.0.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.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
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/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4=
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/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/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 h1:BLNsFR8l/hj/oGjnJXkd4Vi3s4kQD3/3x8HSAE4bzN0=
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
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/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/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.14.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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
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.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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/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.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= lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=

View File

@@ -76,8 +76,8 @@ func main() {
// 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, "no config file found") log.Println(cli.BgRed, "config", cli.Reset, err)
} }
server.RunBlocking(host, port, frontend, localDatabasePath) server.RunBlocking(c.Host, c.Port, frontend, localDatabasePath)
} }

View File

@@ -8,6 +8,8 @@ import (
) )
type Config struct { type Config struct {
CurrentLogFile string
LogPath string `yaml:"log_path"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"` DownloadPath string `yaml:"downloadPath"`
@@ -33,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 {

View File

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

View File

@@ -17,6 +17,11 @@ import (
"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"`

View File

@@ -4,13 +4,12 @@ import (
"encoding/gob" "encoding/gob"
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"github.com/google/uuid" "github.com/google/uuid"
"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"
) )
@@ -94,14 +93,14 @@ func (m *MemoryDB) All() *[]ProcessResponse {
} }
// WIP: Persist the database in a single file named "session.dat" // WIP: Persist the database in a single file named "session.dat"
func (m *MemoryDB) Persist() { func (m *MemoryDB) Persist() error {
running := m.All() running := m.All()
sf := filepath.Join(config.Instance().SessionFilePath, "session.dat") sf := filepath.Join(config.Instance().SessionFilePath, "session.dat")
fd, err := os.Create(sf) fd, err := os.Create(sf)
if err != nil { if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset) return errors.Join(errors.New("failed to persist session"), err)
} }
session := Session{ session := Session{
@@ -110,14 +109,14 @@ func (m *MemoryDB) Persist() {
err = gob.NewEncoder(fd).Encode(session) err = gob.NewEncoder(fd).Encode(session)
if err != nil { if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset) return errors.Join(errors.New("failed to persist session"), err)
} }
log.Println(cli.BgBlue, "Successfully serialized session", cli.Reset) return nil
} }
// WIP: Restore a persisted state // WIP: Restore a persisted state
func (m *MemoryDB) Restore() { 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
@@ -138,6 +137,7 @@ func (m *MemoryDB) Restore() {
Progress: proc.Progress, Progress: proc.Progress,
Output: proc.Output, Output: proc.Output,
Params: proc.Params, Params: proc.Params,
Logger: logger,
} }
m.table.Store(proc.Id, restored) m.table.Store(proc.Id, restored)
@@ -146,6 +146,4 @@ func (m *MemoryDB) Restore() {
go restored.Start() go restored.Start()
} }
} }
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)
} }

View File

@@ -1,8 +1,6 @@
package internal package internal
import ( import (
"log"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
@@ -19,7 +17,7 @@ func NewMessageQueue() *MessageQueue {
size := config.Instance().QueueSize size := config.Instance().QueueSize
if size <= 0 { if size <= 0 {
log.Fatalln("invalid queue size") panic("invalid queue size")
} }
return &MessageQueue{ return &MessageQueue{

View File

@@ -3,12 +3,11 @@ package internal
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"log" "log/slog"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
"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"
) )
@@ -19,7 +18,7 @@ type metadata struct {
Type string `json:"_type"` Type string `json:"_type"`
} }
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error { func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger *slog.Logger) error {
var ( var (
downloader = config.Instance().DownloaderPath downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "-J") cmd = exec.Command(downloader, req.URL, "-J")
@@ -37,14 +36,14 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
return err return err
} }
log.Println(cli.BgRed, "Decoding metadata", cli.Reset, req.URL) logger.Info("decoding metadata", slog.String("url", req.URL))
err = json.NewDecoder(stdout).Decode(&m) err = json.NewDecoder(stdout).Decode(&m)
if err != nil { if err != nil {
return err return err
} }
log.Println(cli.BgGreen, "Decoded metadata", cli.Reset, req.URL) logger.Info("decoded metadata", slog.String("url", req.URL))
if m.Type == "" { if m.Type == "" {
cmd.Wait() cmd.Wait()
@@ -52,8 +51,10 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
} }
if m.Type == "playlist" { if m.Type == "playlist" {
log.Println( logger.Info(
cli.BgGreen, "Playlist detected", cli.Reset, m.Count, "entries", "playlist detected",
slog.String("url", req.URL),
slog.Int("count", m.Count),
) )
for i, meta := range m.Entries { for i, meta := range m.Entries {
@@ -90,11 +91,14 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
return err return err
} }
proc := &Process{Url: req.URL, Params: req.Params} proc := &Process{
Url: req.URL,
Params: req.Params,
Logger: logger,
}
mq.Publish(proc) mq.Publish(proc)
log.Println("Sending new process to message queue", proc.Url) logger.Info("sending new process to message queue", slog.String("url", proc.Url))
err = cmd.Wait() return cmd.Wait()
return err
} }

View File

@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"regexp" "regexp"
"sync" "sync"
"syscall" "syscall"
@@ -50,6 +51,7 @@ type Process struct {
Progress DownloadProgress Progress DownloadProgress
Output DownloadOutput Output DownloadOutput
proc *os.Process proc *os.Process
Logger *slog.Logger
} }
type DownloadOutput struct { type DownloadOutput struct {
@@ -89,16 +91,22 @@ func (p *Process) Start() {
buildFilename(&p.Output) buildFilename(&p.Output)
params := append([]string{ params := []string{
strings.Split(p.Url, "?list")[0], //no playlist strings.Split(p.Url, "?list")[0], //no playlist
"--newline", "--newline",
"--no-colors", "--no-colors",
"--no-playlist", "--no-playlist",
"--progress-template", "--progress-template",
strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(template), strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(template),
"-o", }
fmt.Sprintf("%s/%s", out.Path, out.Filename),
}, p.Params...) // if user asked to manually override the output path...
if !(slices.Includes(params, "-P") || slices.Includes(params, "--paths")) {
params = append(params, "-o")
params = append(params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
}
params = append(params, p.Params...)
// ----------------- main block ----------------- // // ----------------- main block ----------------- //
cmd := exec.Command(config.Instance().DownloaderPath, params...) cmd := exec.Command(config.Instance().DownloaderPath, params...)
@@ -106,13 +114,21 @@ func (p *Process) Start() {
r, err := cmd.StdoutPipe() r, err := cmd.StdoutPipe()
if err != nil { if err != nil {
log.Panicln(err) p.Logger.Error(
"failed to connect to stdout",
slog.String("err", err.Error()),
)
panic(err)
} }
scan := bufio.NewScanner(r) scan := bufio.NewScanner(r)
err = cmd.Start() err = cmd.Start()
if err != nil { if err != nil {
log.Panicln(err) p.Logger.Error(
"failed to start yt-dlp process",
slog.String("err", err.Error()),
)
panic(err)
} }
p.proc = cmd.Process p.proc = cmd.Process
@@ -151,10 +167,10 @@ func (p *Process) Start() {
Speed: stdout.Speed, Speed: stdout.Speed,
ETA: stdout.Eta, ETA: stdout.Eta,
} }
log.Println( p.Logger.Info("progress",
cli.BgGreen, "DL", cli.Reset, slog.String("id", p.getShortId()),
cli.BgBlue, p.getShortId(), cli.Reset, slog.String("url", p.Url),
p.Url, stdout.Percentage, slog.String("percentege", stdout.Percentage),
) )
} }
}) })
@@ -175,12 +191,9 @@ func (p *Process) Complete() {
ETA: 0, ETA: 0,
} }
shortId := p.getShortId() p.Logger.Info("finished",
slog.String("id", p.getShortId()),
log.Println( slog.String("url", p.Url),
cli.BgMagenta, "FINISH", cli.Reset,
cli.BgBlue, shortId, cli.Reset,
p.Url,
) )
} }
@@ -197,7 +210,7 @@ func (p *Process) Kill() error {
} }
err = syscall.Kill(-pgid, syscall.SIGTERM) err = syscall.Kill(-pgid, syscall.SIGTERM)
log.Println("Killed process", p.Id) p.Logger.Info("killed process", slog.String("id", p.Id))
return err return err
} }
@@ -223,16 +236,18 @@ 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,
p.Url, p.Url,
) )
p.Logger.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", p.Url),
)
go func() { go func() {
decodingError = json.Unmarshal(stdout, &info) decodingError = json.Unmarshal(stdout, &info)
wg.Done() wg.Done()
@@ -264,7 +279,11 @@ func (p *Process) SetMetadata() error {
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
log.Println("Cannot retrieve info for", p.Url) p.Logger.Error("failed retrieving info",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", err.Error()),
)
return err return err
} }
@@ -278,10 +297,9 @@ func (p *Process) SetMetadata() error {
return err return err
} }
log.Println( p.Logger.Info("retrieving metadata",
cli.BgRed, "Metadata", cli.Reset, slog.String("id", p.getShortId()),
cli.BgBlue, p.getShortId(), cli.Reset, slog.String("url", p.Url),
p.Url,
) )
err = json.NewDecoder(stdout).Decode(&info) err = json.NewDecoder(stdout).Decode(&info)

80
server/logging/handler.go Normal file
View File

@@ -0,0 +1,80 @@
package logging
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
ReadBufferSize: 1000,
WriteBufferSize: 1000,
}
func webSocket(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for msg := range logsObservable.Observe() {
c.WriteJSON(msg.V)
}
}
func sse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
for msg := range logsObservable.Observe() {
if msg.E != nil {
http.Error(w, msg.E.Error(), http.StatusInternalServerError)
return
}
var (
b bytes.Buffer
sb strings.Builder
)
if err := json.NewEncoder(&b).Encode(msg.V); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sb.WriteString("event: log\n")
sb.WriteString("data: " + b.String() + "\n\n")
fmt.Fprint(w, sb.String())
flusher.Flush()
}
}
func ApplyRouter() func(chi.Router) {
return func(r chi.Router) {
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
r.Get("/ws", webSocket)
r.Get("/sse", sse)
}
}

View File

@@ -0,0 +1,31 @@
package logging
import (
"time"
"github.com/reactivex/rxgo/v2"
)
var (
logsChan = make(chan rxgo.Item, 100)
logsObservable = rxgo.
FromChannel(logsChan, rxgo.WithBackPressureStrategy(rxgo.Drop)).
BufferWithTime(rxgo.WithDuration(time.Millisecond * 500))
)
type ObservableLogger struct{}
func NewObservableLogger() *ObservableLogger {
return &ObservableLogger{}
}
func (o *ObservableLogger) Write(p []byte) (n int, err error) {
go func() {
logsChan <- rxgo.Of(string(p))
}()
n = len(p)
err = nil
return
}

View File

@@ -39,6 +39,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")

View File

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

View File

@@ -12,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()

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log/slog"
"os" "os"
"github.com/google/uuid" "github.com/google/uuid"
@@ -11,9 +12,10 @@ import (
) )
type Service struct { type Service struct {
mdb *internal.MemoryDB mdb *internal.MemoryDB
db *sql.DB db *sql.DB
mq *internal.MessageQueue mq *internal.MessageQueue
logger *slog.Logger
} }
func (s *Service) Exec(req internal.DownloadRequest) (string, error) { func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
@@ -24,6 +26,7 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
Path: req.Path, Path: req.Path,
Filename: req.Rename, Filename: req.Rename,
}, },
Logger: s.logger,
} }
id := s.mdb.Set(p) id := s.mdb.Set(p)

View File

@@ -1,6 +1,8 @@
package rpc package rpc
import ( import (
"log/slog"
"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/internal"
@@ -8,10 +10,15 @@ import (
) )
// Dependency injection container. // Dependency injection container.
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service { func Container(
db *internal.MemoryDB,
mq *internal.MessageQueue,
logger *slog.Logger,
) *Service {
return &Service{ return &Service{
db: db, db: db,
mq: mq, mq: mq,
logger: logger,
} }
} }

View File

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

View File

@@ -1,7 +1,7 @@
package rpc package rpc
import ( import (
"log" "log/slog"
"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" "github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
@@ -9,8 +9,9 @@ import (
) )
type Service struct { type Service struct {
db *internal.MemoryDB db *internal.MemoryDB
mq *internal.MessageQueue mq *internal.MessageQueue
logger *slog.Logger
} }
type Running []internal.ProcessResponse type Running []internal.ProcessResponse
@@ -34,6 +35,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
Path: args.Path, Path: args.Path,
Filename: args.Rename, Filename: args.Rename,
}, },
Logger: s.logger,
} }
s.db.Set(p) s.db.Set(p)
@@ -46,7 +48,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
// Exec spawns a Process. // Exec spawns a Process.
// The result of the execution is the newly spawned process Id. // The result of the execution is the newly spawned process Id.
func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error { func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error {
err := internal.PlaylistDetect(args, s.mq, s.db) err := internal.PlaylistDetect(args, s.mq, s.db, s.logger)
if err != nil { if err != nil {
return err return err
} }
@@ -69,7 +71,7 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
// 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 err error
p := internal.Process{Url: args.URL} p := internal.Process{Url: args.URL, Logger: s.logger}
*meta, err = p.GetFormatsSync() *meta, err = p.GetFormatsSync()
return err return err
} }
@@ -88,7 +90,7 @@ 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 {
log.Println("Trying killing process with 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 {
@@ -106,7 +108,7 @@ func (s *Service) Kill(args string, killed *string) error {
// KillAll kills all process unconditionally and removes them from // KillAll kills all process unconditionally and removes them from
// the memory db // the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error { func (s *Service) KillAll(args NoArgs, killed *string) error {
log.Println("Killing all spawned processes", args) s.logger.Info("Killing all spawned processes")
keys := s.db.Keys() keys := s.db.Keys()
var err error var err error
for _, key := range *keys { for _, key := range *keys {
@@ -125,7 +127,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
// Remove a process from the db rendering it unusable if active // Remove a process from the db rendering it unusable if active
func (s *Service) Clear(args string, killed *string) error { func (s *Service) Clear(args string, killed *string) error {
log.Println("Clearing process with id", args) s.logger.Info("Clearing process with id", slog.String("id", args))
s.db.Delete(args) s.db.Delete(args)
return nil return nil
} }
@@ -148,7 +150,7 @@ func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
// 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 {
log.Println("Updating yt-dlp executable to the latest release") s.logger.Info("Updating yt-dlp executable to the latest release")
err := updater.UpdateExecutable() err := updater.UpdateExecutable()
if err != nil { if err != nil {
*updated = true *updated = true

View File

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

View File

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

View File

@@ -4,8 +4,9 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log" "log/slog"
"net/http" "net/http"
"net/rpc" "net/rpc"
"os" "os"
@@ -14,12 +15,12 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/marcopeocchi/yt-dlp-web-ui/server/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/dbutils"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers" "github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest" "github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc" ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
@@ -29,6 +30,7 @@ import (
type serverConfig struct { type serverConfig struct {
frontend fs.FS frontend fs.FS
logger *slog.Logger
host string host string
port int port int
mdb *internal.MemoryDB mdb *internal.MemoryDB
@@ -38,16 +40,24 @@ type serverConfig struct {
func RunBlocking(host string, port int, frontend fs.FS, dbPath string) { func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
var mdb internal.MemoryDB var mdb internal.MemoryDB
mdb.Restore()
logger := slog.New(
slog.NewTextHandler(
io.MultiWriter(os.Stdout, logging.NewObservableLogger()),
nil,
),
)
mdb.Restore(logger)
db, err := sql.Open("sqlite", dbPath) db, err := sql.Open("sqlite", dbPath)
if err != nil { if err != nil {
log.Fatalln(err) logger.Error("failed to open database", slog.String("err", err.Error()))
} }
err = dbutils.AutoMigrate(context.Background(), db) err = dbutils.AutoMigrate(context.Background(), db)
if err != nil { if err != nil {
log.Fatalln(err) logger.Error("failed to init database", slog.String("err", err.Error()))
} }
mq := internal.NewMessageQueue() mq := internal.NewMessageQueue()
@@ -55,6 +65,7 @@ func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
srv := newServer(serverConfig{ srv := newServer(serverConfig{
frontend: frontend, frontend: frontend,
logger: logger,
host: host, host: host,
port: port, port: port,
mdb: &mdb, mdb: &mdb,
@@ -63,13 +74,17 @@ 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) go autoPersist(time.Minute*5, &mdb, logger)
log.Fatal(srv.ListenAndServe()) logger.Info("yt-dlp-webui started", slog.Int("port", port))
if err := srv.ListenAndServe(); err != nil {
logger.Warn("http server stopped", slog.String("err", err.Error()))
}
} }
func newServer(c serverConfig) *http.Server { func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.mdb, c.mq) service := ytdlpRPC.Container(c.mdb, c.mq, c.logger)
rpc.Register(service) rpc.Register(service)
r := chi.NewRouter() r := chi.NewRouter()
@@ -89,11 +104,10 @@ func newServer(c serverConfig) *http.Server {
}) })
r.Use(corsMiddleware.Handler) r.Use(corsMiddleware.Handler)
r.Use(middleware.Logger) // use in dev
// r.Use(middleware.Logger)
app := http.FileServer(http.FS(c.frontend)) r.Mount("/", http.FileServer(http.FS(c.frontend)))
r.Mount("/", app)
// Archive routes // Archive routes
r.Route("/archive", func(r chi.Router) { r.Route("/archive", func(r chi.Router) {
@@ -118,6 +132,9 @@ func newServer(c serverConfig) *http.Server {
// REST API handlers // REST API handlers
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mdb, c.mq)) r.Route("/api/v1", rest.ApplyRouter(c.db, c.mdb, c.mq))
// Logging
r.Route("/log", logging.ApplyRouter())
return &http.Server{ return &http.Server{
Addr: fmt.Sprintf("%s:%d", c.host, c.port), Addr: fmt.Sprintf("%s:%d", c.host, c.port),
Handler: r, Handler: r,
@@ -133,7 +150,7 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
go func() { go func() {
<-ctx.Done() <-ctx.Done()
log.Println("shutdown signal received") slog.Info("shutdown signal received")
defer func() { defer func() {
db.Persist() db.Persist()
@@ -143,9 +160,15 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
}() }()
} }
func autoPersist(d time.Duration, db *internal.MemoryDB) { func autoPersist(d time.Duration, db *internal.MemoryDB, logger *slog.Logger) {
for { for {
db.Persist() if err := db.Persist(); err != nil {
logger.Info(
"failed to persisted session",
slog.String("err", err.Error()),
)
}
logger.Info("sucessfully persisted session")
time.Sleep(d) time.Sleep(d)
} }
} }

55
server/utils/logrotate.go Normal file
View File

@@ -0,0 +1,55 @@
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
}