Compare commits

...

45 Commits

Author SHA1 Message Date
Marco Piovanello
fb2642de2c added "language" to the Format struct (#194) 2024-09-06 10:23:08 +02:00
ffc7f29688 small code refactoring 2024-09-05 15:41:12 +02:00
2c30bff45d changed memory_db internals to sync.Map to map+iterators+mutex 2024-09-05 15:32:51 +02:00
dc7a0ae6b7 temporary stop of nix CI 2024-09-05 15:24:03 +02:00
d3cf53c609 added livestream endpoints to REST API 2024-09-05 15:23:38 +02:00
Marco Piovanello
0555277c50 Create nix.yml 2024-08-28 21:13:39 +02:00
Marco Piovanello
aa4b168c44 Update .gitattributes 2024-08-28 21:00:25 +02:00
Emanuel Johnson Godin
650f1cad92 Add Nix (#177)
* add Nix support

* fix formatter output

* mention Nix in README

* fix common import

* fix frontend old version import

* clarified flake pkgs order

* rm old dataDir option

* comment typo

* fix password assertion

* rm old User/Group logic

* rewrite assertion boolean expr

* General flake touchup

- Rewrite `callPackage` exprs to be more readable
- Add pre-commit support for devShell
- Add direnv support

* add simple test

* use correct test func
2024-08-28 20:58:46 +02:00
Marco Piovanello
8eebd424c8 Update README.md 2024-08-26 15:34:31 +02:00
Marco Piovanello
a1a3aaca18 Update README.md 2024-08-26 13:15:48 +02:00
Marco Piovanello
d779830df6 Update livestream.go 2024-08-26 13:08:51 +02:00
4375cd3ebc use --no-part for livestreams 2024-08-26 10:24:08 +02:00
b0c7c13e5e code refactoring, removed unused rx package 2024-08-26 10:18:14 +02:00
Marco Piovanello
bb4db5d342 Use cookies saved server side (#188)
* retrieve cookies stored server side

fixed netscape cookies validation pipeline

* code refactoring
2024-08-26 10:09:02 +02:00
Marco Piovanello
64df0e0b32 fix use old enum status values (#187) 2024-08-26 10:08:40 +02:00
MFWT
72c9634832 Update Chinese translation (#185)
add the Chinese translation about the livestream downloading
2024-08-24 18:21:11 +02:00
a4cfc53581 livestream code refactoring 2024-08-24 13:59:13 +02:00
d4feefd639 livestream code refactoring 2024-08-24 13:07:07 +02:00
434efc79d8 code refactoring, dependencies update 2024-08-23 20:31:47 +02:00
54771b2d78 resuse the message queue for livestream downloading 2024-08-23 18:52:13 +02:00
fceb36c723 code refactoring: cancellation signal for stdout parsers 2024-08-23 11:54:10 +02:00
c4075fb640 ready for 3.2.0 2024-08-21 11:43:55 +02:00
Aaron Gershman
aa8191b0cd filemame to filename (#182)
Typo on filemame fixed to filename
2024-08-21 11:29:10 +02:00
a6626973ac apply loading backdrop when loading livestreams 2024-08-21 11:23:34 +02:00
79f1473c6a fixed livestream process not properly killed 2024-08-21 11:16:44 +02:00
Marco Piovanello
b76f2b72be Update README.md 2024-08-20 20:31:39 +02:00
8f2d9eaf6e code refactoring 2024-08-20 20:29:32 +02:00
ja49619
c51f320a6f Update i18n.yaml (#181)
Update translation for i18n.yaml to RU language
2024-08-20 20:14:27 +02:00
8b26bf513f code refactoring 2024-08-20 19:11:54 +02:00
25210ccc22 code refactoring 2024-08-20 19:04:10 +02:00
3205711bb1 improved livestream waiting 2024-08-20 18:50:42 +02:00
92e3fd994e code refactoring / fix typos 2024-08-20 09:42:04 +02:00
01e9da61eb code refactoring 2024-08-19 22:24:38 +02:00
Marco Piovanello
fd5e62e23b Feat livestream support (#180)
* experimental livestrea support

* test livestream

* update wait time detection

* update livestream functions

* persist and restore livestreams monitor session

* fan-in logging

* deps update

* added live time display

* livestream monitor prototype

* changed to default logger instead of passing *slog.Logger everywhere

* code refactoring, comments
2024-08-19 22:08:09 +02:00
a64798644a code refactoring
fixed bad escape in i18n.yaml
2024-08-19 10:25:25 +02:00
Emanuel Johnson Godin
b7511eb064 i18n: add swedish (#179)
* i18n: add swedish

* Fix spelling mistakes and minor rewording
2024-08-19 10:08:50 +02:00
Marco Piovanello
16f8f74f9b Delete frontend/yarn.lock 2024-08-16 14:17:14 +02:00
61891839d9 added info in templates editor 2024-08-01 11:06:10 +02:00
Marco Piovanello
17cd2d54d0 corrected typo (#174) 2024-07-31 15:07:14 +02:00
9b61436a05 openid code refactoring 2024-07-24 09:58:37 +02:00
31e3cfab24 code refactoring 2024-07-23 19:23:51 +02:00
ed437ec367 oid configuration hotfix 2024-07-23 19:23:13 +02:00
Marco Piovanello
92aabc0086 OpenID authentification (#170)
* openid authentification

* openid middleware

* openId login

* tidied login page

* removed useless email text field
2024-07-23 19:04:05 +02:00
38a0cedc9c fixed empty url in format selection
Closes #163
2024-07-14 15:52:36 +02:00
c0c2fcb009 fix "default templates are re-added upon restart"
Mentioned in #161
2024-07-08 10:19:44 +02:00
70 changed files with 2479 additions and 3685 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
*.tsx linguist-detectable=false
*.html linguist-detectable=false

6
.gitignore vendored
View File

@@ -1,3 +1,7 @@
.pre-commit-config.yaml
.direnv/
result/
result
dist
.pnpm-debug.log
node_modules
@@ -19,3 +23,5 @@ ui/
frontend/.pnp.cjs
frontend/.pnp.loader.mjs
frontend/.yarn/install-state.gz
.db.lock
livestreams.dat

View File

@@ -1,19 +1,8 @@
> [!IMPORTANT]
> Major frontend refactoring in progress.
> I won't add features or fix minor issues until completition.
---
# yt-dlp Web UI
A not so terrible web ui for yt-dlp.
Created for the only purpose of *fetching* videos from my server/nas.
Intended to be used with docker and in standalone mode. 😎👍
Developed to be as lightweight as possible (because my server is basically an intel atom sbc).
The bottleneck remains yt-dlp startup time.
**Docker images are available on [Docker Hub](https://hub.docker.com/r/marcobaobao/yt-dlp-webui) or [ghcr.io](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui)**.
```sh
@@ -24,45 +13,9 @@ docker pull marcobaobao/yt-dlp-webui
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
```
## Video showcase
[app.webm](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/91545bc4-233d-4dde-8504-27422cb26964)
![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)
### Integrated File browser
Stream or download your content, easily.
![](https://i.ibb.co/k0qzLds/image.png)
## Changelog
```
05/03/22: Korean translation by kimpig
03/03/22: cut-down image size by switching to Alpine linux based container
01/03/22: Chinese translation by deluxghost
03/02/22: i18n enabled! I need help with the translations :/
27/01/22: Multidownload implemented!
26/01/22: Multiple downloads are being implemented. Maybe by next release they will be there.
Refactoring and JSDoc.
04/01/22: Background jobs now are retrieved!! It's still rudimentary but it leverages on yt-dlp resume feature.
05/05/22: Material UI update.
03/06/22: The most requested feature finally implemented: Format Selection!!
08/06/22: ARM builds.
28/06/22: Reworked resume download feature. Now it's pratically instantaneous. It no longer stops and restarts each process, references to each process are saved in memory.
12/01/23: Switched from TypeScript to Golang on the backend. It was a great effort but it was worth it.
```
## Settings
The currently avaible settings are:
@@ -76,23 +29,13 @@ The currently avaible settings are:
- Pass custom yt-dlp arguments safely
- Download queue (limit concurrent downloads)
![](https://i.ibb.co/YdBVcgc/image.png)
![](https://i.ibb.co/Sf102b1/image.png)
## Format selection
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!
## Troubleshooting
- **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).
- **The download doesn't start.**
- 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 run
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) run
```sh
docker pull marcobaobao/yt-dlp-webui
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
@@ -182,7 +125,7 @@ Usage yt-dlp-webui:
-port int
Port where server will listen at (default 3033)
-qs int
Download queue size (default 8)
Download queue size (defaults to the number of logical CPU. A min of 2 is recomended.)
-user string
Username required for auth
-pass string
@@ -192,6 +135,7 @@ Usage yt-dlp-webui:
### Config file
By running `yt-dlp-webui` in standalone mode you have the ability to also specify a config file.
The config file **will overwrite what have been passed as cli argument**.
With Docker, inside the mounted `/conf` volume inside there must be a file named `config.yml`.
```yaml
# Simple configuration file for yt-dlp webui
@@ -289,17 +233,17 @@ Want to build your own frontend? We got you covered 🤠
`yt-dlp-webui` now exposes a nice **JSON-RPC 1.0** interface through Websockets and HTTP-POST
It is **planned** to also expose a **gRPC** server.
Just as an overview, these are the available methods:
- Service.Exec
- Service.Progress
- Service.Formats
- Service.Pending
- Service.Running
- Service.Kill
- Service.KillAll
- Service.Clear
For more information open an issue on GitHub and I will provide more info ASAP.
## Nix
This repo adds support for Nix(OS) in various ways through a `flake-parts` flake.
For more info, please refer to the [official documentation](https://nixos.org/learn/).
## 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.
## Troubleshooting
- **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).
- **The download doesn't start.**
- 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)

View File

@@ -1,4 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs.buildPackages; [ yt-dlp nodejs_22 yarn-berry go ];
}

149
flake.lock generated Normal file
View File

@@ -0,0 +1,149 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1722555600,
"narHash": "sha256-XOQkdLafnb/p9ij77byFQjDf5m5QYl9b2REiVClC+x4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "8471fe90ad337a8074e957b69ca4d0089218391d",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1723637854,
"narHash": "sha256-med8+5DSWa2UnOqtdICndjDAEjxr5D7zaIiK4pn0Q7c=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c3aa7b8938b17aebd2deecf7be0636000d62a2b9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1722555339,
"narHash": "sha256-uFf2QeW7eAHlYXuDktm9c25OxOyCoUOQmh5SZ9amE5Q=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/a5d394176e64ab29c852d03346c1fc9b0b7d33eb.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/a5d394176e64ab29c852d03346c1fc9b0b7d33eb.tar.gz"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1720386169,
"narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "194846768975b7ad2c4988bdb82572c00222c0d7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1719082008,
"narHash": "sha256-jHJSUH619zBQ6WdC21fFAlDxHErKVDJ5fpN0Hgx4sjs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9693852a2070b398ee123a329e68f0dab5526681",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks-nix": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs_2",
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1723803910,
"narHash": "sha256-yezvUuFiEnCFbGuwj/bQcqg7RykIEqudOy/RBrId0pc=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "bfef0ada09e2c8ac55bbcd0831bd0c9d42e651ba",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"pre-commit-hooks-nix": "pre-commit-hooks-nix"
}
}
},
"root": "root",
"version": 7
}

51
flake.nix Normal file
View File

@@ -0,0 +1,51 @@
{
description = "A terrible web ui for yt-dlp. Designed to be self-hosted.";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
pre-commit-hooks-nix.url = "github:cachix/pre-commit-hooks.nix";
};
outputs = inputs@{ self, flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.pre-commit-hooks-nix.flakeModule
];
systems = [
"x86_64-linux"
];
perSystem = { config, self', pkgs, ... }: {
packages = {
yt-dlp-web-ui-frontend = pkgs.callPackage ./nix/frontend.nix { };
default = pkgs.callPackage ./nix/server.nix {
inherit (self'.packages) yt-dlp-web-ui-frontend;
};
};
checks = import ./nix/tests { inherit self pkgs; };
pre-commit = {
check.enable = true;
settings = {
hooks = {
${self'.formatter.pname}.enable = true;
deadnix.enable = true;
nil.enable = true;
statix.enable = true;
};
};
};
devShells.default = pkgs.callPackage ./nix/devShell.nix {
inputsFrom = [ config.pre-commit.devShell ];
};
formatter = pkgs.nixpkgs-fmt;
};
flake = {
nixosModules.default = import ./nix/module.nix self.packages;
};
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "yt-dlp-webui",
"version": "3.1.0",
"version": "3.2.0",
"description": "Frontend compontent of yt-dlp-webui",
"scripts": {
"dev": "vite",

View File

@@ -2,6 +2,7 @@ import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard'
import LiveTvIcon from '@mui/icons-material/LiveTv'
import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings'
import TerminalIcon from '@mui/icons-material/Terminal'
@@ -121,6 +122,19 @@ export default function Layout() {
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/monitor'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<LiveTvIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/log'} style={
{
textDecoration: 'none',

View File

@@ -24,9 +24,9 @@ languages:
overridesAnchor: Overrides
pathOverrideOption: Enable output path overriding
filenameOverrideOption: Enable output file name overriding
customFilename: Custom filemame (leave blank to use default)
customFilename: Custom filename (leave blank to use default)
customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgs: Enable custom yt-dlp args (great power = great responsibilities)
customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server
splashText: No active downloads
@@ -53,6 +53,18 @@ languages:
bulkDownload: 'Download files in a zip archive'
rpcPollingTimeTitle: RPC polling time
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
templatesReloadInfo: To register a new template it might need a page reload.
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
german:
urlInput: Video URL
statusTitle: Status
@@ -77,7 +89,7 @@ languages:
overridesAnchor: Überschreibungen
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
customFilename: Custom filemame (leave blank to use default)
customFilename: Custom filename (leave blank to use default)
customPath: Benutzerdefinierter Pfad
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
customArgsInput: Benutzerdefinierte yt-dlp Argumente
@@ -103,6 +115,17 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut
@@ -155,6 +178,17 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
italian:
urlInput: URL Video (uno per linea)
statusTitle: Stato
@@ -178,7 +212,7 @@ languages:
overridesAnchor: Sovrascritture
pathOverrideOption: Abilita sovrascrittura percorso di output
filenameOverrideOption: Abilita sovrascrittura del nome del file di output
customFilename: Custom filemame (leave blank to use default)
customFilename: Custom filename (leave blank to use default)
customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments
@@ -204,6 +238,17 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
chinese:
urlInput: 视频 URL
statusTitle: 状态
@@ -253,7 +298,18 @@ languages:
templatesEditorContentLabel: 模板内容
logsTitle: '日志'
awaitingLogs: '正在等待日志…'
bulkDownload: 'Download files in a zip archive'
bulkDownload: '下载 zip 压缩包中的文件'
livestreamURLInput: 直播 URL
livestreamStatusWaiting: 等待直播开始
livestreamStatusDownloading: 下载中
livestreamStatusCompleted: 已完成
livestreamStatusErrored: 发生错误
livestreamStatusUnknown: 未知
livestreamDownloadInfo: |
本功能将会监控即将开始的直播流,每个进程都会传入参数:--wait-for-video 10 重试间隔10秒
如果直播已经开始,那么依然可以下载,但是不会记录下载进度。
直播开始后,将会转移到下载页面
livestreamExperimentalWarning: 实验性功能可能存在未知Bug请谨慎使用
spanish:
urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado
@@ -302,6 +358,17 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус
@@ -333,23 +400,34 @@ languages:
splashText: Нет активных загрузок
archiveTitle: Архив
clipboardAction: URL скопирован в буфер обмена
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
newDownloadButton: New download
playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно
servedFromReverseProxyCheckbox: Находится за обратным прокси
newDownloadButton: Новая загрузка
homeButtonLabel: Home
archiveButtonLabel: Archive
settingsButtonLabel: Settings
rpcAuthenticationLabel: RPC authentication
themeTogglerLabel: Theme toggler
loadingLabel: Loading...
appTitle: App title
savedTemplates: Saved templates
templatesEditor: Templates editor
templatesEditorNameLabel: Template name
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
archiveButtonLabel: Архив
settingsButtonLabel: Настройки
rpcAuthenticationLabel: RPC-аутентификация
themeTogglerLabel: Переключить тему
loadingLabel: Загрузка...
appTitle: Название приложения
savedTemplates: Сохраненные шаблоны
templatesEditor: Редактор шаблонов
templatesEditorNameLabel: Имя шаблона
templatesEditorContentLabel: Содержание шаблона
logsTitle: 'Логи'
awaitingLogs: 'Ожидание логов...'
bulkDownload: 'Скачать файлы в zip архиве'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태
@@ -373,7 +451,7 @@ languages:
overridesAnchor: Overrides
pathOverrideOption: Enable output path overriding
filenameOverrideOption: Enable output file name overriding
customFilename: Custom filemame (leave blank to use default)
customFilename: Custom filename (leave blank to use default)
customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments
@@ -398,6 +476,17 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
japanese:
urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態
@@ -447,6 +536,17 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
catalan:
urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat
@@ -495,6 +595,17 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус
@@ -543,6 +654,17 @@ languages:
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status
@@ -590,4 +712,80 @@ languages:
templatesEditorContentLabel: Template content
logsTitle: 'Logs'
awaitingLogs: 'Awaiting logs...'
bulkDownload: 'Download files in a zip archive'
bulkDownload: 'Download files in a zip archive'
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!
swedish:
urlInput: Videolänk (en per rad)
statusTitle: Status
statusReady: Redo
selectFormatButton: Välj format
startButton: Start
abortAllButton: Avbryt alla
updateBinButton: Uppdatera yt-dlp
darkThemeButton: Mörkt tema
lightThemeButton: Ljust tema
settingsAnchor: Inställningar
serverAddressTitle: Serveraddress
serverPortTitle: Port
extractAudioCheckbox: Extrahera ljud
noMTimeCheckbox: Lägg inte till info om när filen senast modifierades
bgReminder: När du stänger denna sida så kommer nedladdningen att fortsätta i bakgrunden.
toastConnected: 'Ansluten till '
toastUpdated: Uppdaterade yt-dlp!
formatSelectionEnabler: Tillåt val av ljud- och bildformat
themeSelect: 'Tema'
languageSelect: 'Språk'
overridesAnchor: Överskrivningar
pathOverrideOption: Tillåt överskrivning av filsökvägen
filenameOverrideOption: Tillåt överskrivning av filnamn
customFilename: Eget filnamn (lämna blankt för standardnamn)
customPath: Egen filsökväg
customArgs: Tillåt egna yt-dlp-argument (frihet under ansvar!)
customArgsInput: Egna yt-dlp-argument
rpcConnErr: Ett fel inträffade vid anslutning till RPC-server
splashText: Inga pågående nedladdningar
archiveTitle: Arkiv
clipboardAction: Kopierade länken
playlistCheckbox: Ladda ner spellista (detta kommer ta did, efter start så kan du stänga detta fönster)
restartAppMessage: En sidomladdning behövs innan förändringen får effekt
servedFromReverseProxyCheckbox: Servern befinner sig bakom en omvänd proxy
urlBase: "URL-bas, måste anges när en omvänd proxy används. Standardinställning: lämna blank"
newDownloadButton: Ny nedladdning
homeButtonLabel: Hem
archiveButtonLabel: Arkiv
settingsButtonLabel: Inställningar
rpcAuthenticationLabel: RPC-Autentisering
themeTogglerLabel: Tema-knapp
loadingLabel: Laddar...
appTitle: Apptitel
savedTemplates: Sparade mallar
templatesEditor: Mallredigerare
templatesEditorNameLabel: Namn
templatesEditorContentLabel: Innehåll
logsTitle: 'Loggar'
awaitingLogs: 'Väntar på loggar...'
bulkDownload: 'Ladda ner filer i ett zip-arkiv'
rpcPollingTimeTitle: Frekvens av RPC-uppdateringar
rpcPollingTimeDescription: En högre frekvens kräver mer CPU-resurser för både server och klient
templatesReloadInfo: För att registrera en ny mall så kan en sidomladdning krävas.
livestreamURLInput: Livestream URL
livestreamStatusWaiting: Waiting/Wait start
livestreamStatusDownloading: Downloading
livestreamStatusCompleted: Completed
livestreamStatusErrored: Errored
livestreamStatusUnknown: Unknown
livestreamDownloadInfo: |
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.
If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break!

View File

@@ -1,16 +1,15 @@
import { atom, selector } from 'recoil'
import { CustomTemplate } from '../types'
import { ffetch } from '../lib/httpClient'
import { serverURL } from './settings'
import { pipe } from 'fp-ts/lib/function'
import { getOrElse } from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/function'
import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient'
import { CustomTemplate } from '../types'
import { serverSideCookiesState, serverURL } from './settings'
export const cookiesTemplateState = atom({
export const cookiesTemplateState = selector({
key: 'cookiesTemplateState',
default: localStorage.getItem('cookiesTemplate') ?? '',
effects: [
({ onSet }) => onSet(e => localStorage.setItem('cookiesTemplate', e))
]
get: ({ get }) => get(serverSideCookiesState)
? '--cookies=cookies.txt'
: ''
})
export const customArgsState = atom({

View File

@@ -1,4 +1,7 @@
import { pipe } from 'fp-ts/lib/function'
import { matchW } from 'fp-ts/lib/TaskEither'
import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient'
import { prefersDarkMode } from '../utils'
export const languages = [
@@ -187,13 +190,15 @@ export const rpcHTTPEndpoint = selector({
}
})
export const cookiesState = atom({
key: 'cookiesState',
default: localStorage.getItem('yt-dlp-cookies') ?? '',
effects: [
({ onSet }) =>
onSet(c => localStorage.setItem('yt-dlp-cookies', c))
]
export const serverSideCookiesState = selector<string>({
key: 'serverSideCookiesState',
get: async ({ get }) => await pipe(
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
matchW(
() => '',
(r) => r.cookies
)
)()
})
const themeSelector = selector<ThemeNarrowed>({

View File

@@ -1,5 +1,10 @@
import { pipe } from 'fp-ts/lib/function'
import { of } from 'fp-ts/lib/Task'
import { getOrElse } from 'fp-ts/lib/TaskEither'
import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient'
import { rpcClientState } from './rpc'
import { serverURL } from './settings'
export const connectedState = atom({
key: 'connectedState',
@@ -22,4 +27,15 @@ export const availableDownloadPathsState = selector({
.catch(() => ({ result: [] }))
return res.result
}
})
export const ytdlpVersionState = selector<string>({
key: 'ytdlpVersionState',
get: async ({ get }) => await pipe(
ffetch<string>(`${get(serverURL)}/api/v1/version`),
getOrElse(() => pipe(
'unknown version',
of
)),
)()
})

View File

@@ -1,22 +1,20 @@
import { TextField } from '@mui/material'
import { Button, TextField } from '@mui/material'
import * as A from 'fp-ts/Array'
import * as E from 'fp-ts/Either'
import * as O from 'fp-ts/Option'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
import { useMemo } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { useRecoilValue } from 'recoil'
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { cookiesTemplateState } from '../atoms/downloadTemplate'
import { cookiesState, serverURL } from '../atoms/settings'
import { serverSideCookiesState, serverURL } from '../atoms/settings'
import { useSubscription } from '../hooks/observable'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
const validateCookie = (cookie: string) => pipe(
cookie,
cookie => cookie.replace(/\s\s+/g, ' '),
cookie => cookie.replaceAll('\t', ' '),
cookie => cookie.split(' '),
cookie => cookie.split('\t'),
E.of,
E.flatMap(
E.fromPredicate(
@@ -68,13 +66,19 @@ const validateCookie = (cookie: string) => pipe(
),
)
const noopValidator = (s: string): E.Either<string, string[]> => pipe(
s,
s => s.split('\t'),
E.of
)
const isCommentOrNewLine = (s: string) => s === '' || s.startsWith('\n') || s.startsWith('#')
const CookiesTextField: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const [, setCookies] = useRecoilState(cookiesTemplateState)
const [savedCookies, setSavedCookies] = useRecoilState(cookiesState)
const savedCookies = useRecoilValue(serverSideCookiesState)
const { pushMessage } = useToast()
const flag = '--cookies=cookies.txt'
const cookies$ = useMemo(() => new Subject<string>(), [])
@@ -86,28 +90,41 @@ const CookiesTextField: React.FC = () => {
})
})()
const deleteCookies = () => pipe(
ffetch(`${serverAddr}/api/v1/cookies`, {
method: 'DELETE',
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => {
pushMessage('Deleted cookies', 'success')
pushMessage(`Reload the page to apply the changes`, 'info')
}
)
)()
const validateNetscapeCookies = (cookies: string) => pipe(
cookies,
cookies => cookies.split('\n'),
cookies => cookies.filter(f => !f.startsWith('\n')), // empty lines
cookies => cookies.filter(f => !f.startsWith('# ')), // comments
cookies => cookies.filter(Boolean), // empty lines
A.map(validateCookie),
A.mapWithIndex((i, either) => pipe(
A.map(c => isCommentOrNewLine(c) ? noopValidator(c) : validateCookie(c)), // validate line
A.mapWithIndex((i, either) => pipe( // detect errors and return the either
either,
E.matchW(
(l) => pushMessage(`Error in line ${i + 1}: ${l}`, 'warning'),
() => E.isRight(either)
E.match(
(l) => {
pushMessage(`Error in line ${i + 1}: ${l}`, 'warning')
return either
},
(_) => either
),
)),
A.filter(Boolean),
A.match(
() => false,
(c) => {
pushMessage(`Valid ${c.length} Netscape cookies`, 'info')
return true
}
)
A.filter(c => E.isRight(c)), // filter the line who didn't pass the validation
A.map(E.getOrElse(() => new Array<string>())), // cast the array of eithers to an array of tokens
A.filter(f => f.length > 0), // filter the empty tokens
A.map(f => f.join('\t')), // join the tokens in a TAB separated string
A.reduce('', (c, n) => `${c}${n}\n`), // reduce all to a single string separated by \n
parsed => parsed.length > 0 // if nothing has passed the validation return none
? O.some(parsed)
: O.none
)
useSubscription(
@@ -117,22 +134,17 @@ const CookiesTextField: React.FC = () => {
),
(cookies) => pipe(
cookies,
cookies => {
setSavedCookies(cookies)
return cookies
},
validateNetscapeCookies,
O.fromPredicate(f => f === true),
O.match(
() => setCookies(''),
async () => {
() => pushMessage('No valid cookies', 'warning'),
async (some) => {
pipe(
await submitCookies(cookies),
await submitCookies(some.trimEnd()),
E.match(
(l) => pushMessage(`${l}`, 'error'),
() => {
pushMessage(`Saved Netscape cookies`, 'success')
setCookies(flag)
pushMessage(`Saved ${some.split('\n').length} Netscape cookies`, 'success')
pushMessage('Reload the page to apply the changes', 'info')
}
)
)
@@ -142,15 +154,18 @@ const CookiesTextField: React.FC = () => {
)
return (
<TextField
label="Netscape Cookies"
multiline
maxRows={20}
minRows={4}
fullWidth
defaultValue={savedCookies}
onChange={(e) => cookies$.next(e.currentTarget.value)}
/>
<>
<TextField
label="Netscape Cookies"
multiline
maxRows={20}
minRows={4}
fullWidth
defaultValue={savedCookies}
onChange={(e) => cookies$.next(e.currentTarget.value)}
/>
<Button onClick={deleteCookies}>Delete cookies</Button>
</>
)
}

View File

@@ -125,7 +125,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
* Retrive url from input and display the formats selection view
*/
const sendUrlFormatSelection = () => {
setUrl('')
setPickedAudioFormat('')
setPickedVideoFormat('')
setPickedBestFormat('')

View File

@@ -14,7 +14,7 @@ const DownloadsGridView: React.FC = () => {
const { client } = useRPC()
const { pushMessage } = useToast()
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.Completed
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
? client.clear(r.id)
: client.kill(r.id)

View File

@@ -133,7 +133,7 @@ const DownloadsTableView: React.FC = () => {
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
}
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.Completed
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
? client.clear(r.id)
: client.kill(r.id)

View File

@@ -35,8 +35,11 @@ const Footer: React.FC = () => {
display: 'flex', gap: 1, justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<Chip label="RPC v3.1.0" variant="outlined" size="small" />
<VersionIndicator />
{/* TODO: make it dynamic */}
<Chip label="RPC v3.2.0" variant="outlined" size="small" />
<Suspense>
<VersionIndicator />
</Suspense>
</div>
<div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}>
<div style={{

View File

@@ -101,6 +101,7 @@ export default function FormatsGrid({
>
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
{(format.filesize_approx > 0) ? " (~" + Math.round(format.filesize_approx / 1024 / 1024) + " MiB)" : ""}
{format.language}
</Button>
</Grid>
))

View File

@@ -1,10 +1,10 @@
import { Backdrop, CircularProgress } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
const LoadingBackdrop: React.FC = () => {
const isLoading = useRecoilValue(loadingAtom)
type Props = {
isLoading: boolean
}
const LoadingBackdrop: React.FC<Props> = ({ isLoading }) => {
return (
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}

View File

@@ -2,6 +2,7 @@ import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
import DeleteIcon from '@mui/icons-material/Delete'
import {
Alert,
AppBar,
Backdrop,
Box,
@@ -145,7 +146,12 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Grid container spacing={2} sx={{ p: 4 }}>
<Grid container spacing={2} sx={{ px: 4, pt: 3, pb: 4 }}>
<Grid item>
<Alert severity="info">
{i18n.t('templatesReloadInfo')}
</Alert>
</Grid>
<Grid item xs={12}>
<Paper
elevation={4}

View File

@@ -1,32 +1,9 @@
import { Chip, CircularProgress } from '@mui/material'
import { useEffect, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { ytdlpVersionState } from '../atoms/status'
const VersionIndicator: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
const [version, setVersion] = useState('')
const { pushMessage } = useToast()
const fetchVersion = async () => {
const res = await fetch(`${serverAddr}/api/v1/version`, {
headers: {
'X-Authentication': localStorage.getItem('token') ?? ''
}
})
if (!res.ok) {
return pushMessage(await res.text(), 'error')
}
setVersion(await res.json())
}
useEffect(() => {
fetchVersion()
}, [])
const version = useRecoilValue(ytdlpVersionState)
return (
version

View File

@@ -0,0 +1,125 @@
import CloseIcon from '@mui/icons-material/Close'
import {
Alert,
AppBar,
Box,
Button,
Container,
Dialog,
Grid,
IconButton,
Paper,
Slide,
TextField,
Toolbar,
Typography
} from '@mui/material'
import { TransitionProps } from '@mui/material/transitions'
import { forwardRef, useState } from 'react'
import { useToast } from '../../hooks/toast'
import { useI18n } from '../../hooks/useI18n'
import { useRPC } from '../../hooks/useRPC'
type Props = {
open: boolean
onClose: () => void
}
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />
})
const LivestreamDialog: React.FC<Props> = ({ open, onClose }) => {
const [livestreamURL, setLivestreamURL] = useState('')
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const exec = (url: string) => client.execLivestream(url)
return (
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={onClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Livestream monitor
</Typography>
</Toolbar>
</AppBar>
<Box sx={{
backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Container sx={{ my: 4 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
elevation={4}
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<Grid item xs={12} mb={2}>
<Alert severity="info">
{i18n.t('livestreamDownloadInfo')}
</Alert>
<Alert severity="warning" sx={{ mt: 1 }}>
{i18n.t('livestreamExperimentalWarning')}
</Alert>
</Grid>
<Grid item xs={12}>
<TextField
multiline
fullWidth
label={i18n.t('livestreamURLInput')}
variant="outlined"
onChange={(e) => setLivestreamURL(e.target.value)}
/>
</Grid>
<Grid item>
<Button
sx={{ mt: 2 }}
variant="contained"
disabled={livestreamURL === ''}
onClick={() => {
exec(livestreamURL)
onClose()
pushMessage(`Monitoring ${livestreamURL}`, 'info')
}}
>
{i18n.t('startButton')}
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Container>
</Box>
</Dialog>
)
}
export default LivestreamDialog

View File

@@ -0,0 +1,34 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material'
import { useI18n } from '../../hooks/useI18n'
type Props = {
onOpen: () => void
onStopAll: () => void
}
const LivestreamSpeedDial: React.FC<Props> = ({ onOpen, onStopAll }) => {
const { i18n } = useI18n()
return (
<SpeedDial
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={onStopAll}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={i18n.t('newDownloadButton')}
onClick={onOpen}
/>
</SpeedDial>
)
}
export default LivestreamSpeedDial

View File

@@ -0,0 +1,38 @@
import LiveTvIcon from '@mui/icons-material/LiveTv'
import { Container, SvgIcon, Typography, styled } from '@mui/material'
import { useI18n } from '../../hooks/useI18n'
const FlexContainer = styled(Container)({
display: 'flex',
minWidth: '100%',
minHeight: '80vh',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
})
const Title = styled(Typography)({
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: '0.5rem'
})
export default function NoLivestreams() {
const { i18n } = useI18n()
return (
<FlexContainer>
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
<SvgIcon sx={{ fontSize: '200px' }}>
<LiveTvIcon />
</SvgIcon>
</Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
No livestreams monitored
</Title>
</FlexContainer>
)
}

View File

@@ -1,5 +1,5 @@
import { Observable } from 'rxjs'
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
import type { DLMetadata, LiveStreamProgress, RPCRequest, RPCResponse, RPCResult } from '../types'
import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
@@ -82,7 +82,9 @@ export class RPCClient {
: ''
const sanitizedArgs = this.argsSanitizer(
req.args.replace('-o', '').replace(rename, '')
req.args
.replace('-o', '')
.replace(rename, '')
)
if (req.playlist) {
@@ -160,9 +162,32 @@ export class RPCClient {
})
}
public updateExecutable() {
public execLivestream(url: string) {
return this.sendHTTP({
method: 'Service.UpdateExecutable',
method: 'Service.ExecLivestream',
params: [{
URL: url
}]
})
}
public progressLivestream() {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.ProgressLivestream',
params: []
})
}
public killLivestream(url: string) {
return this.sendHTTP({
method: 'Service.KillLivestream',
params: [url]
})
}
public killAllLivestream() {
return this.sendHTTP({
method: 'Service.KillAllLivestream',
params: []
})
}

View File

@@ -8,6 +8,7 @@ const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login'))
const Archive = lazy(() => import('./views/Archive'))
const Settings = lazy(() => import('./views/Settings'))
const LiveStream = lazy(() => import('./views/Livestream'))
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
@@ -74,6 +75,14 @@ export const router = createHashRouter([
</Suspense >
)
},
{
path: '/monitor',
element: (
<Suspense fallback={<CircularProgress />}>
<LiveStream />
</Suspense >
)
},
]
},
])

View File

@@ -9,6 +9,10 @@ export type RPCMethods =
| "Service.ExecPlaylist"
| "Service.DirectoryTree"
| "Service.UpdateExecutable"
| "Service.ExecLivestream"
| "Service.ProgressLivestream"
| "Service.KillLivestream"
| "Service.KillAllLivestream"
export type RPCRequest = {
method: RPCMethods
@@ -35,10 +39,11 @@ type DownloadInfo = {
}
export enum ProcessStatus {
Pending = 0,
Downloading,
Completed,
Errored,
PENDING = 0,
DOWNLOADING,
COMPLETED,
ERRORED,
LIVESTREAM,
}
type DownloadProgress = {
@@ -77,6 +82,7 @@ export type DLFormat = {
vcodec: string
acodec: string
filesize_approx: number
language: string
}
export type DirectoryEntry = {
@@ -96,4 +102,17 @@ export type CustomTemplate = {
id: string
name: string
content: string
}
}
export enum LiveStreamStatus {
WAITING,
IN_PROGRESS,
COMPLETED,
ERRORED
}
export type LiveStreamProgress = Record<string, {
status: LiveStreamStatus
waitTime: string
liveDate: string
}>

View File

@@ -56,14 +56,16 @@ export function isRPCResponse(object: any): object is RPCResponse<any> {
export function mapProcessStatus(status: ProcessStatus) {
switch (status) {
case ProcessStatus.Pending:
case ProcessStatus.PENDING:
return 'Pending'
case ProcessStatus.Downloading:
case ProcessStatus.DOWNLOADING:
return 'Downloading'
case ProcessStatus.Completed:
case ProcessStatus.COMPLETED:
return 'Completed'
case ProcessStatus.Errored:
case ProcessStatus.ERRORED:
return 'Error'
case ProcessStatus.LIVESTREAM:
return 'Livestream'
default:
return 'Pending'
}

View File

@@ -1,15 +1,19 @@
import {
Container
} from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
import Downloads from '../components/Downloads'
import HomeActions from '../components/HomeActions'
import LoadingBackdrop from '../components/LoadingBackdrop'
import Splash from '../components/Splash'
export default function Home() {
const isLoading = useRecoilValue(loadingAtom)
return (
<Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}>
<LoadingBackdrop />
<LoadingBackdrop isLoading={isLoading} />
<Splash />
<Downloads />
<HomeActions />

View File

@@ -0,0 +1,134 @@
import {
Box,
Button,
Chip,
Container,
Paper,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
} from '@mui/material'
import { useState } from 'react'
import { interval } from 'rxjs'
import LivestreamDialog from '../components/livestream/LivestreamDialog'
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
import NoLivestreams from '../components/livestream/NoLivestreams'
import LoadingBackdrop from '../components/LoadingBackdrop'
import { useSubscription } from '../hooks/observable'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { LiveStreamProgress, LiveStreamStatus } from '../types'
const LiveStreamMonitorView: React.FC = () => {
const { i18n } = useI18n()
const { client } = useRPC()
const [progress, setProgress] = useState<LiveStreamProgress>()
const [openDialog, setOpenDialog] = useState(false)
useSubscription(interval(1000), () => {
client
.progressLivestream()
.then(r => setProgress(r.result))
})
const formatMicro = (microseconds: number) => {
const ms = microseconds / 1_000_000
let s = ms / 1000
const hr = s / 3600
s %= 3600
const mt = s / 60
s %= 60
// huh?
const ss = (Math.abs(s - 1)).toFixed(0).padStart(2, '0')
const mts = mt.toFixed(0).padStart(2, '0')
const hrs = hr.toFixed(0).padStart(2, '0')
return `${hrs}:${mts}:${ss}`
}
const mapStatusToChip = (status: LiveStreamStatus): React.ReactNode => {
switch (status) {
case LiveStreamStatus.WAITING:
return <Chip label='Waiting/Wait start' color='warning' size='small' />
case LiveStreamStatus.IN_PROGRESS:
return <Chip label='Downloading' color='primary' size='small' />
case LiveStreamStatus.COMPLETED:
return <Chip label='Completed' color='success' size='small' />
case LiveStreamStatus.ERRORED:
return <Chip label='Errored' color='error' size='small' />
default:
return <Chip label='Unknown state' color='secondary' size='small' />
}
}
const stopAll = () => client.killAllLivestream()
const stop = (url: string) => client.killLivestream(url)
return (
<>
<LoadingBackdrop isLoading={!progress} />
<LivestreamSpeedDial onOpen={() => setOpenDialog(s => !s)} onStopAll={stopAll} />
<LivestreamDialog open={openDialog} onClose={() => setOpenDialog(s => !s)} />
{!progress || Object.keys(progress).length === 0 ?
<NoLivestreams /> :
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Paper sx={{
p: 2.5,
display: 'flex',
flexDirection: 'column',
minHeight: '80vh',
}}>
<TableContainer component={Box}>
<Table sx={{ minWidth: '100%' }}>
<TableHead>
<TableRow>
<TableCell>{i18n.t('livestreamURLInput')}</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right">Time to live</TableCell>
<TableCell align="right">Starts on</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{progress && Object.keys(progress).map(k => (
<TableRow
key={k}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell>{k}</TableCell>
<TableCell align='right'>
{mapStatusToChip(progress[k].status)}
</TableCell>
<TableCell align='right'>
{progress[k].status === LiveStreamStatus.WAITING
? formatMicro(Number(progress[k].waitTime))
: "-"
}
</TableCell>
<TableCell align='right'>
{progress[k].status === LiveStreamStatus.WAITING
? new Date(progress[k].liveDate).toLocaleString()
: "-"
}
</TableCell>
<TableCell align='right'>
<Button variant='contained' size='small' onClick={() => stop(k)}>
Stop
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Container>}
</>
)
}
export default LiveStreamMonitorView

View File

@@ -6,19 +6,20 @@ import styled from '@emotion/styled'
import {
Button,
Container,
Divider,
Paper,
Stack,
TextField,
Typography
} from '@mui/material'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function'
const LoginContainer = styled(Container)({
display: 'flex',
@@ -81,6 +82,8 @@ export default function Login() {
)()
}
const loginWithOpenId = () => window.open(`${url}/auth/openid/login`)
return (
<LoginContainer>
<Paper sx={{ padding: '1.5rem', minWidth: '25%' }}>
@@ -89,12 +92,8 @@ export default function Login() {
yt-dlp WebUI
</Title>
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
Authentication token will expire after 30 days.
</Title>
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
In order to enable RPC authentication append the --auth,
<br />
--user [username] and --pass [password] flags.
To configure authentication check the&nbsp;
<a href='https://github.com/marcopeocchi/yt-dlp-web-ui/wiki/Authentication-methods'>wiki</a>.
</Title>
<TextField
label="Username"
@@ -113,6 +112,16 @@ export default function Login() {
<Button variant="contained" size="large" onClick={() => login()}>
Submit
</Button>
<Divider>
<Typography color={'gray'}>
or use your authentication provider
</Typography>
</Divider>
<Button variant="contained" size="large" onClick={loginWithOpenId}>
Login with OpenID
</Button>
</Stack>
</Paper>
</LoginContainer>

View File

@@ -18,7 +18,7 @@ import {
Typography,
capitalize
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRecoilState } from 'recoil'
import {
Subject,
@@ -347,7 +347,9 @@ export default function Settings() {
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
Cookies
</Typography>
<CookiesTextField />
<Suspense>
<CookiesTextField />
</Suspense>
</Grid>
<Grid>
<Stack direction="row">

File diff suppressed because it is too large Load Diff

25
go.mod
View File

@@ -1,26 +1,29 @@
module github.com/marcopeocchi/yt-dlp-web-ui
go 1.22
go 1.23
require (
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
github.com/go-chi/chi/v5 v5.0.12
github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/gorilla/websocket v1.5.3
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/oauth2 v0.23.0
golang.org/x/sync v0.8.0
golang.org/x/sys v0.25.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.5
modernc.org/sqlite v1.32.0
)
require (
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
@@ -29,11 +32,11 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/teivah/onecontext v1.3.0 // indirect
golang.org/x/net v0.22.0 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.47.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
modernc.org/libc v1.60.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

81
go.sum
View File

@@ -1,8 +1,10 @@
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.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.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -11,18 +13,22 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
@@ -32,8 +38,6 @@ 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/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-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -55,27 +59,33 @@ github.com/teivah/onecontext v1.3.0/go.mod h1:hoW1nmdPVK/0jrvGtcx8sCKYs2PiS4z0zz
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/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
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.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.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.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
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.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.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.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
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 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
@@ -83,28 +93,31 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.19.5 h1:QlsZyQ1zf78DGeqnQ9ILi9hXyMdoC5e1qoGNUyBjHQw=
modernc.org/cc/v4 v4.19.5/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.13.0 h1:99E8QHRoPrXN8VpS0zgAgJ5nSjpXrPKpsJIMvGL/2Oc=
modernc.org/ccgo/v4 v4.13.0/go.mod h1:Td6RI9W9G2ZpKHaJ7UeGEiB2aIpoDqLBnm4wtkbJTbQ=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.20.7 h1:skrinQsjxWfvj6nbC3ztZPJy+NuwmB3hV9zX/pthNYQ=
modernc.org/ccgo/v4 v4.20.7/go.mod h1:UOkI3JSG2zT4E2ioHlncSOZsXbuDCZLvPi3uMlZT5GY=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.47.0 h1:BXrzId9fOOkBtS+uFQ5aZyVGmt7WcSEPrXF5Kwsho90=
modernc.org/libc v1.47.0/go.mod h1:gzCncw0a74aCiVqHeWAYHHaW//fkSHHS/3S/gfhLlCI=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.59.9 h1:k+nNDDakwipimgmJ1D9H466LhFeSkaPPycAs1OZiDmY=
modernc.org/libc v1.59.9/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A=
modernc.org/libc v1.60.1 h1:at373l8IFRTkJIkAU85BIuUoBM4T1b51ds0E1ovPG2s=
modernc.org/libc v1.60.1/go.mod h1:xJuobKuNxKH3RUatS7GjR+suWj+5c2K7bi4m/S5arOY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE=
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -11,6 +11,7 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server"
"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/openid"
)
var (
@@ -91,6 +92,8 @@ func main() {
log.Println(cli.BgRed, "config", cli.Reset, err)
}
openid.Configure()
server.RunBlocking(&server.RunConfig{
Host: c.Host,
Port: c.Port,

9
nix/common.nix Normal file
View File

@@ -0,0 +1,9 @@
{ lib }: {
version = "v3.1.2";
meta = {
description = "A terrible web ui for yt-dlp. Designed to be self-hosted.";
homepage = "https://github.com/marcopeocchi/yt-dlp-web-ui";
license = lib.licenses.mpl20;
};
}

9
nix/devShell.nix Normal file
View File

@@ -0,0 +1,9 @@
{ inputsFrom ? [ ], mkShell, yt-dlp, nodejs, go }:
mkShell {
inherit inputsFrom;
packages = [
yt-dlp
nodejs
go
];
}

37
nix/frontend.nix Normal file
View File

@@ -0,0 +1,37 @@
{ lib
, stdenv
, nodejs
, pnpm
}:
let common = import ./common.nix { inherit lib; }; in
stdenv.mkDerivation (finalAttrs: {
pname = "yt-dlp-web-ui-frontend";
inherit (common) version;
src = lib.fileset.toSource {
root = ../frontend;
fileset = ../frontend;
};
buildPhase = ''
npm run build
'';
installPhase = ''
mkdir -p $out/dist
cp -r dist/* $out/dist
'';
nativeBuildInputs = [
nodejs
pnpm.configHook
];
pnpmDeps = pnpm.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-NvXNDXkuoJ4vGeQA3bOhhc+KLBfke593qK0edcvzWTo=";
};
inherit (common) meta;
})

215
nix/module.nix Normal file
View File

@@ -0,0 +1,215 @@
packages: { config, lib, pkgs, ... }:
let
cfg = config.services.yt-dlp-web-ui;
inherit (pkgs.stdenv.hostPlatform) system;
pkg = packages.${system}.default;
in
{
/*
Some notes on the module design:
- Usually, you don't map out all of the options like this in attrsets,
but due to the software's nonstandard "config file overrides CLI" behavior,
we don't want to expose a config file catchall, and as such don't use '-conf'.
- Notably, '-driver' is missing as a configuration option.
This should instead be customized with idiomatic Nix, overriding 'cfg.package' with
the desired yt-dlp package.
- The systemd service has been sandboxed as much as possible. This restricts configuration of
data and logs dir. If you really need a custom data and logs dir, use BindPaths (man systemd.exec)
*/
options.services.yt-dlp-web-ui = {
enable = lib.mkEnableOption "yt-dlp-web-ui";
package = lib.mkOption {
type = lib.types.package;
default = pkg;
defaultText = lib.literalMD "`packages.default` from the yt-dlp-web-ui flake.";
description = ''
The yt-dlp-web-ui package to use.
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "yt-dlp-web-ui";
description = lib.mdDoc ''
User under which yt-dlp-web-ui runs.
'';
};
group = lib.mkOption {
type = lib.types.str;
default = "yt-dlp-web-ui";
description = lib.mdDoc ''
Group under which yt-dlp-web-ui runs.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = lib.mdDoc ''
Whether to open the TCP port in the firewall.
'';
};
host = lib.mkOption {
default = "0.0.0.0";
type = lib.types.str;
description = lib.mdDoc ''
Host where yt-dlp-web-ui will listen at.
'';
};
port = lib.mkOption {
default = 3033;
type = lib.types.port;
description = lib.mdDoc ''
Port where yt-dlp-web-ui will listen at.
'';
};
downloadDir = lib.mkOption {
type = lib.types.str;
description = lib.mdDoc ''
The directory where yt-dlp-web-ui stores downloads.
'';
};
queueSize = lib.mkOption {
default = 2;
type = lib.types.ints.unsigned; # >= 0
description = lib.mdDoc ''
Queue size (concurrent downloads).
'';
};
logging = lib.mkEnableOption "logging";
rpcAuth = lib.mkOption {
description = lib.mdDoc ''
RPC Authentication settings.
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "RPC authentication";
user = lib.mkOption {
type = lib.types.str;
description = lib.mdDoc ''
Username required for auth.
'';
};
passwordFile = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = lib.mdDoc ''
Path to the file containing the password required for auth.
'';
};
insecurePasswordText = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = lib.mdDoc ''
Raw password required for auth.
It's strongly recommended to use 'passwordFile' instead of this option.
**Don't use this option unless you know what you're doing!**.
It writes the password to the world-readable Nix store, which is a big security risk.
More info: https://wiki.nixos.org/wiki/Comparison_of_secret_managing_schemes
'';
};
};
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
(lib.mkIf cfg.rpcAuth.enable {
assertion = lib.xor (cfg.rpcAuth.passwordFile == null) (cfg.rpcAuth.insecurePasswordText == null);
message = ''
RPC Auth is enabled for yt-dlp-web-ui! Exactly one RPC auth password source must be set!
Tip: You should set 'services.yt-dlp-web-ui.rpcAuth.passwordfile'!
'';
})
];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
users.users = lib.mkIf (cfg.user == "yt-dlp-web-ui") {
yt-dlp-web-ui = {
inherit (cfg) group;
isSystemUser = true;
};
};
users.groups = lib.mkIf (cfg.group == "yt-dlp-web-ui") { yt-dlp-web-ui = { }; };
systemd.services.yt-dlp-web-ui = {
description = "yt-dlp-web-ui system service";
after = [ "network.target" ];
path = [ cfg.package pkgs.tree ];
wantedBy = [ "multi-user.target" ];
serviceConfig =
rec {
ExecStart =
let
password =
if cfg.rpcAuth.passwordFile == null
then cfg.rpcAuth.insecurePasswordText
else "$(cat ${cfg.rpcAuth.passwordFile})";
args = [
"-host ${cfg.host}"
"-port ${builtins.toString cfg.port}"
''-out "${cfg.downloadDir}"''
"-qs ${builtins.toString cfg.queueSize}"
] ++ (lib.optionals cfg.logging [
"-fl"
''-lf "/var/log/${LogsDirectory}/yt-dlp-web-ui.log"''
]) ++ (lib.optionals cfg.rpcAuth.enable [
"-auth"
"-user ${cfg.rpcAuth.user}"
"-pass ${password}"
]);
in
"${lib.getExe cfg.package} ${lib.concatStringsSep " " args}";
User = cfg.user;
Group = cfg.group;
ProtectSystem = "strict";
ProtectHome = "read-only";
StateDirectory = "yt-dlp-web-ui";
WorkingDirectory = "/var/lib/${StateDirectory}"; # equivalent to the dir above
LogsDirectory = "yt-dlp-web-ui";
ReadWritePaths = [
cfg.downloadDir
];
BindReadOnlyPaths = [
builtins.storeDir
# required for youtube DNS lookup
"${config.environment.etc."ssl/certs/ca-certificates.crt".source}:/etc/ssl/certs/ca-certificates.crt"
] ++ lib.optionals (cfg.rpcAuth.enable && cfg.rpcAuth.passwordFile != null) [
cfg.rpcAuth.passwordFile
];
CapabilityBoundingSet = "";
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
PrivateDevices = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged" ];
RestrictRealtime = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
ProtectHostname = true;
};
};
};
}

52
nix/server.nix Normal file
View File

@@ -0,0 +1,52 @@
{ yt-dlp-web-ui-frontend, buildGoModule, lib, makeWrapper, yt-dlp, ... }:
let
fs = lib.fileset;
common = import ./common.nix { inherit lib; };
in
buildGoModule {
pname = "yt-dlp-web-ui";
inherit (common) version;
src = fs.toSource rec {
root = ../.;
fileset = fs.difference root (fs.unions [
### LIST OF FILES TO IGNORE ###
# frontend (this is included by the frontend.nix drv instead)
../frontend
# documentation
../examples
# docker
../Dockerfile
../docker-compose.yml
# nix
./devShell.nix
../.envrc
./tests
# make
../Makefile # this derivation does not use the project Makefile
# repo commons
../.github
../README.md
../LICENSE.md
../.gitignore
../.vscode
]);
};
# https://github.com/golang/go/issues/44507
preBuild = ''
cp -r ${yt-dlp-web-ui-frontend} frontend
'';
nativeBuildInputs = [ makeWrapper ];
postInstall = ''
wrapProgram $out/bin/yt-dlp-web-ui \
--prefix PATH : ${lib.makeBinPath [ yt-dlp ]}
'';
vendorHash = "sha256-guM/U9DROJMx2ctPKBQis1YRhaf6fKvvwEWgswQKMG0=";
meta = common.meta // {
mainProgram = "yt-dlp-web-ui";
};
}

20
nix/tests/default.nix Normal file
View File

@@ -0,0 +1,20 @@
{ self, pkgs }: {
testServiceStarts = pkgs.testers.runNixOSTest (_: {
name = "service-starts";
nodes = {
machine = _: {
imports = [
self.nixosModules.default
];
services.yt-dlp-web-ui = {
enable = true;
downloadDir = "/var/lib/yt-dlp-web-ui";
};
};
};
testScript = ''
machine.wait_for_unit("yt-dlp-web-ui")
'';
});
}

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
@@ -20,6 +21,13 @@ type Config struct {
Password string `yaml:"password"`
QueueSize int `yaml:"queue_size"`
SessionFilePath string `yaml:"session_file_path"`
path string
UseOpenId bool `yaml:"use_openid"`
OpenIdProviderURL string `yaml:"openid_provider_url"`
OpenIdClientId string `yaml:"openid_client_id"`
OpenIdClientSecret string `yaml:"openid_client_secret"`
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
}
var (
@@ -43,9 +51,17 @@ func (c *Config) LoadFile(filename string) error {
return err
}
c.path = filename
if err := yaml.NewDecoder(fd).Decode(c); err != nil {
return err
}
return nil
}
// Path of the directory containing the config file
func (c *Config) Dir() string { return filepath.Dir(c.path) }
// Absolute path of the config file
func (c *Config) Path() string { return c.path }

View File

@@ -3,29 +3,41 @@ package dbutil
import (
"context"
"database/sql"
"os"
"path/filepath"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock")
// Run the table migration
func AutoMigrate(ctx context.Context, db *sql.DB) error {
func Migrate(ctx context.Context, db *sql.DB) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
defer func() {
conn.Close()
createLockFile()
}()
_, err = db.ExecContext(
if _, err := db.ExecContext(
ctx,
`CREATE TABLE IF NOT EXISTS templates (
id CHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content TEXT NOT NULL
)`,
)
if err != nil {
); err != nil {
return err
}
if lockFileExists() {
return nil
}
db.ExecContext(
ctx,
`INSERT INTO templates (id, name, content) VALUES
@@ -35,5 +47,12 @@ func AutoMigrate(ctx context.Context, db *sql.DB) error {
"1", "audio only", "-x",
)
return err
return nil
}
func createLockFile() { os.Create(lockFilePath) }
func lockFileExists() bool {
_, err := os.Stat(lockFilePath)
return os.IsExist(err)
}

View File

@@ -158,7 +158,6 @@ func SendFile(w http.ResponseWriter, r *http.Request) {
root := config.Instance().DownloadPath
// TODO: further path / file validations
if strings.Contains(filepath.Dir(filename), root) {
http.ServeFile(w, r, filename)
return

View File

@@ -58,6 +58,7 @@ type Format struct {
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Size float32 `json:"filesize_approx"`
Language string `json:"language"`
}
// struct representing the response sent to the client

View File

@@ -0,0 +1,215 @@
package livestream
import (
"bufio"
"errors"
"io"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
const (
waiting = iota
inProgress
completed
errored
)
// Defines a generic livestream.
// A livestream is identified by its url.
type LiveStream struct {
url string
proc *os.Process // used to manually kill the yt-dlp process
status int // whether is monitoring or completed
done chan *LiveStream // where to signal the completition
waitTimeChan chan time.Duration // time to livestream start
waitTime time.Duration
liveDate time.Time
mq *internal.MessageQueue
db *internal.MemoryDB
}
func New(url string, done chan *LiveStream, mq *internal.MessageQueue, db *internal.MemoryDB) *LiveStream {
return &LiveStream{
url: url,
done: done,
status: waiting,
waitTime: time.Second * 0,
waitTimeChan: make(chan time.Duration),
mq: mq,
db: db,
}
}
// Start the livestream monitoring process, once completion signals on the done channel
func (l *LiveStream) Start() error {
cmd := exec.Command(
config.Instance().DownloaderPath,
l.url,
"--wait-for-video", "30", // wait for the stream to be live and recheck every 10 secs
"--no-colors", // no ansi color fuzz
"--simulate",
"--newline",
"--paths", config.Instance().DownloadPath,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
l.status = errored
return err
}
defer stdout.Close()
if err := cmd.Start(); err != nil {
l.status = errored
return err
}
l.proc = cmd.Process
l.status = waiting
// Start monitoring when the livestream is goin to be live.
// If already live do nothing.
go l.monitorStartTime(stdout)
// Wait to the simulated download process to finish.
cmd.Wait()
// Set the job as completed and notify the parent the completion.
l.status = completed
l.done <- l
// Send the started livestream to the message queue! :D
p := &internal.Process{
Url: l.url,
Livestream: true,
Params: []string{"--downloader", "ffmpeg", "--no-part"},
}
l.db.Set(p)
l.mq.Publish(p)
return nil
}
func (l *LiveStream) monitorStartTime(r io.Reader) {
// yt-dlp shows the time in the stdout
scanner := bufio.NewScanner(r)
defer func() {
l.status = inProgress
close(l.waitTimeChan)
}()
// however the time to live is not shown in a new line (and atm there's nothing to do about)
// use a custom split funciton to set the line separator to \r instead of \r\n or \n
scanner.Split(stdoutSplitFunc)
waitTimeScanner := func() {
for scanner.Scan() {
// l.log <- scanner.Bytes()
// if this substring is in the current line the download is starting,
// no need to monitor the time to live.
//TODO: silly
if !strings.Contains(scanner.Text(), "Remaining time until next attempt") {
return
}
parts := strings.Split(scanner.Text(), ": ")
if len(parts) < 2 {
continue
}
startsIn := parts[1]
parsed, err := parseTimeSpan(startsIn)
if err != nil {
continue
}
l.liveDate = parsed
//TODO: check if using channels is stupid or not
// l.waitTimeChan <- time.Until(start)
l.waitTime = time.Until(parsed)
}
}
const TRIES = 5
/*
if it's waiting a livestream the 5th line will indicate the time to live
its a dumb and not robust method.
example:
[youtube] Extracting URL: https://www.youtube.com/watch?v=IQVbGfVVjgY
[youtube] IQVbGfVVjgY: Downloading webpage
[youtube] IQVbGfVVjgY: Downloading ios player API JSON
[youtube] IQVbGfVVjgY: Downloading web creator player API JSON
WARNING: [youtube] This live event will begin in 27 minutes. <- STDERR, ignore
[wait] Waiting for 00:27:15 - Press Ctrl+C to try now <- 5th line
*/
for range TRIES {
scanner.Scan()
if strings.Contains(scanner.Text(), "Waiting for") {
waitTimeScanner()
}
}
}
func (l *LiveStream) WaitTime() <-chan time.Duration {
return l.waitTimeChan
}
// Kills a livestream process and signal its completition
func (l *LiveStream) Kill() error {
l.done <- l
if l.proc != nil {
return l.proc.Kill()
}
return errors.New("nil yt-dlp process")
}
// Parse the timespan returned from yt-dlp (time to live)
//
// parsed := parseTimeSpan("76:12:15")
// fmt.Println(parsed) // 2024-07-21 13:59:59.634781 +0200 CEST
func parseTimeSpan(timeStr string) (time.Time, error) {
parts := strings.Split(timeStr, ":")
hh, err := strconv.Atoi(parts[0])
if err != nil {
return time.Time{}, err
}
mm, err := strconv.Atoi(parts[1])
if err != nil {
return time.Time{}, err
}
ss, err := strconv.Atoi(parts[2])
if err != nil {
return time.Time{}, err
}
dd := 0
if hh > 24 {
dd = hh / 24
hh = hh % 24
}
start := time.Now()
start = start.AddDate(0, 0, dd)
start = start.Add(time.Duration(hh) * time.Hour)
start = start.Add(time.Duration(mm) * time.Minute)
start = start.Add(time.Duration(ss) * time.Second)
return start, nil
}

View File

@@ -0,0 +1,36 @@
package livestream
import (
"testing"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
func setupTest() {
config.Instance().DownloaderPath = "yt-dlp"
}
func TestLivestream(t *testing.T) {
setupTest()
done := make(chan *LiveStream)
ls := New("https://www.youtube.com/watch?v=LSm1daKezcE", done, &internal.MessageQueue{}, &internal.MemoryDB{})
go ls.Start()
time.AfterFunc(time.Second*20, func() {
ls.Kill()
})
for {
select {
case wt := <-ls.WaitTime():
t.Log(wt)
case <-done:
t.Log("done")
return
}
}
}

View File

@@ -0,0 +1,112 @@
package livestream
import (
"encoding/gob"
"log/slog"
"os"
"path/filepath"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
type Monitor struct {
db *internal.MemoryDB // where the just started livestream will be published
mq *internal.MessageQueue // where the just started livestream will be published
streams map[string]*LiveStream // keeps track of the livestreams
done chan *LiveStream // to signal individual processes completition
}
func NewMonitor(mq *internal.MessageQueue, db *internal.MemoryDB) *Monitor {
return &Monitor{
mq: mq,
db: db,
streams: make(map[string]*LiveStream),
done: make(chan *LiveStream),
}
}
// Detect each livestream completition, if done detach it from the monitor.
func (m *Monitor) Schedule() {
for l := range m.done {
delete(m.streams, l.url)
}
}
func (m *Monitor) Add(url string) {
ls := New(url, m.done, m.mq, m.db)
go ls.Start()
m.streams[url] = ls
}
func (m *Monitor) Remove(url string) error {
return m.streams[url].Kill()
}
func (m *Monitor) RemoveAll() error {
for _, v := range m.streams {
if err := v.Kill(); err != nil {
return err
}
}
return nil
}
func (m *Monitor) Status() LiveStreamStatus {
status := make(LiveStreamStatus)
for k, v := range m.streams {
// wt, ok := <-v.WaitTime()
// if !ok {
// continue
// }
status[k] = Status{
Status: v.status,
WaitTime: v.waitTime,
LiveDate: v.liveDate,
}
}
return status
}
// Persist the monitor current state to a file.
// The file is located in the configured config directory
func (m *Monitor) Persist() error {
fd, err := os.Create(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
if err != nil {
return err
}
defer fd.Close()
slog.Debug("persisting livestream monitor state")
return gob.NewEncoder(fd).Encode(m.streams)
}
// Restore a saved state and resume the monitored livestreams
func (m *Monitor) Restore() error {
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
if err != nil {
return err
}
defer fd.Close()
restored := make(map[string]*LiveStream)
if err := gob.NewDecoder(fd).Decode(&restored); err != nil {
return err
}
for k := range restored {
m.Add(k)
}
slog.Debug("restored livestream monitor state")
return nil
}

View File

@@ -0,0 +1 @@
package livestream

View File

@@ -0,0 +1,11 @@
package livestream
import "time"
type LiveStreamStatus = map[string]Status
type Status = struct {
Status int `json:"status"`
WaitTime time.Duration `json:"waitTime"`
LiveDate time.Time `json:"liveDate"`
}

View File

@@ -0,0 +1,16 @@
package livestream
import "bufio"
var stdoutSplitFunc = func(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i := 0; i < len(data); i++ {
if data[i] == '\r' || data[i] == '\n' {
return i + 1, data[:i], nil
}
}
if !atEOF {
return 0, nil, nil
}
return 0, data, bufio.ErrFinalToken
}

View File

@@ -3,7 +3,6 @@ package internal
import (
"encoding/gob"
"errors"
"log/slog"
"os"
"path/filepath"
"sync"
@@ -14,41 +13,57 @@ import (
// In-Memory Thread-Safe Key-Value Storage with optional persistence
type MemoryDB struct {
table sync.Map
table map[string]*Process
mu sync.RWMutex
}
func NewMemoryDB() *MemoryDB {
return &MemoryDB{
table: make(map[string]*Process),
}
}
// Get a process pointer given its id
func (m *MemoryDB) Get(id string) (*Process, error) {
entry, ok := m.table.Load(id)
m.mu.RLock()
defer m.mu.RUnlock()
entry, ok := m.table[id]
if !ok {
return nil, errors.New("no process found for the given key")
}
return entry.(*Process), nil
return entry, nil
}
// Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string {
id := uuid.NewString()
m.table.Store(id, process)
m.mu.Lock()
process.Id = id
m.table[id] = process
m.mu.Unlock()
return id
}
// Removes a process progress, given the process id
func (m *MemoryDB) Delete(id string) {
m.table.Delete(id)
m.mu.Lock()
delete(m.table, id)
m.mu.Unlock()
}
func (m *MemoryDB) Keys() *[]string {
var running []string
m.table.Range(func(key, value any) bool {
running = append(running, key.(string))
return true
})
m.mu.RLock()
defer m.mu.RUnlock()
for id := range m.table {
running = append(running, id)
}
return &running
}
@@ -57,16 +72,17 @@ func (m *MemoryDB) Keys() *[]string {
func (m *MemoryDB) All() *[]ProcessResponse {
running := []ProcessResponse{}
m.table.Range(func(key, value any) bool {
m.mu.RLock()
for k, v := range m.table {
running = append(running, ProcessResponse{
Id: key.(string),
Info: value.(*Process).Info,
Progress: value.(*Process).Progress,
Output: value.(*Process).Output,
Params: value.(*Process).Params,
Id: k,
Info: v.Info,
Progress: v.Progress,
Output: v.Output,
Params: v.Params,
})
return true
})
}
m.mu.RUnlock()
return &running
}
@@ -82,6 +98,8 @@ func (m *MemoryDB) Persist() error {
return errors.Join(errors.New("failed to persist session"), err)
}
m.mu.RLock()
defer m.mu.RUnlock()
session := Session{Processes: *running}
if err := gob.NewEncoder(fd).Encode(session); err != nil {
@@ -92,7 +110,7 @@ func (m *MemoryDB) Persist() error {
}
// Restore a persisted state
func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) {
func (m *MemoryDB) Restore(mq *MessageQueue) {
fd, err := os.Open("session.dat")
if err != nil {
return
@@ -104,6 +122,9 @@ func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) {
return
}
m.mu.Lock()
defer m.mu.Unlock()
for _, proc := range session.Processes {
restored := &Process{
Id: proc.Id,
@@ -112,10 +133,9 @@ func (m *MemoryDB) Restore(mq *MessageQueue, logger *slog.Logger) {
Progress: proc.Progress,
Output: proc.Output,
Params: proc.Params,
Logger: logger,
}
m.table.Store(proc.Id, restored)
m.table[proc.Id] = restored
if restored.Progress.Status != StatusCompleted {
mq.Publish(restored)

View File

@@ -15,14 +15,13 @@ const queueName = "process:pending"
type MessageQueue struct {
concurrency int
eventBus evbus.Bus
logger *slog.Logger
}
// Creates a new message queue.
// By default it will be created with a size equals to nthe number of logical
// CPU cores -1.
// The queue size can be set via the qs flag.
func NewMessageQueue(l *slog.Logger) (*MessageQueue, error) {
func NewMessageQueue() (*MessageQueue, error) {
qs := config.Instance().QueueSize
if qs <= 0 {
@@ -32,7 +31,6 @@ func NewMessageQueue(l *slog.Logger) (*MessageQueue, error) {
return &MessageQueue{
concurrency: qs,
eventBus: evbus.New(),
logger: l,
}, nil
}
@@ -55,24 +53,27 @@ func (m *MessageQueue) downloadConsumer() {
sem := semaphore.NewWeighted(int64(m.concurrency))
m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.Background(), 1)
defer sem.Release(1)
m.logger.Info("received process from event bus",
slog.Info("received process from event bus",
slog.String("bus", queueName),
slog.String("consumer", "downloadConsumer"),
slog.String("id", p.getShortId()),
)
if p.Progress.Status != StatusCompleted {
p.Start()
slog.Info("started process",
slog.String("bus", queueName),
slog.String("id", p.getShortId()),
)
if p.Livestream {
// livestreams have higher priorty and they ignore the semaphore
go p.Start()
} else {
p.Start()
}
}
m.logger.Info("started process",
slog.String("bus", queueName),
slog.String("id", p.getShortId()),
)
}, false)
}
@@ -84,18 +85,17 @@ func (m *MessageQueue) metadataSubscriber() {
sem := semaphore.NewWeighted(1)
m.eventBus.SubscribeAsync(queueName, func(p *Process) {
//TODO: provide valid context
sem.Acquire(context.TODO(), 1)
sem.Acquire(context.Background(), 1)
defer sem.Release(1)
m.logger.Info("received process from event bus",
slog.Info("received process from event bus",
slog.String("bus", queueName),
slog.String("consumer", "metadataConsumer"),
slog.String("id", p.getShortId()),
)
if p.Progress.Status == StatusCompleted {
m.logger.Warn("proccess has an illegal state",
slog.Warn("proccess has an illegal state",
slog.String("id", p.getShortId()),
slog.Int("status", p.Progress.Status),
)
@@ -103,7 +103,7 @@ func (m *MessageQueue) metadataSubscriber() {
}
if err := p.SetMetadata(); err != nil {
m.logger.Error("failed to retrieve metadata",
slog.Error("failed to retrieve metadata",
slog.String("id", p.getShortId()),
slog.String("err", err.Error()),
)

View File

@@ -19,7 +19,7 @@ type metadata struct {
Type string `json:"_type"`
}
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger *slog.Logger) error {
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
var (
downloader = config.Instance().DownloaderPath
cmd = exec.Command(downloader, req.URL, "--flat-playlist", "-J")
@@ -36,7 +36,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
return err
}
logger.Info("decoding playlist metadata", slog.String("url", req.URL))
slog.Info("decoding playlist metadata", slog.String("url", req.URL))
if err := json.NewDecoder(stdout).Decode(&m); err != nil {
return err
@@ -46,7 +46,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
return err
}
logger.Info("decoded playlist metadata", slog.String("url", req.URL))
slog.Info("decoded playlist metadata", slog.String("url", req.URL))
if m.Type == "" {
return errors.New("probably not a valid URL")
@@ -57,7 +57,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
return a.URL == b.URL
})
logger.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries)))
slog.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries)))
for i, meta := range entries {
// detect playlist title from metadata since each playlist entry will be
@@ -69,7 +69,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
1,
)
//TODO: it's idiotic but it works: virtually delay the creation time
//XXX: it's idiotic but it works: virtually delay the creation time
meta.CreatedAt = time.Now().Add(time.Millisecond * time.Duration(i*10))
proc := &Process{
@@ -78,7 +78,6 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
Output: DownloadOutput{Filename: req.Rename},
Info: meta,
Params: req.Params,
Logger: logger,
}
proc.Info.URL = meta.URL
@@ -93,12 +92,11 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger
proc := &Process{
Url: req.URL,
Params: req.Params,
Logger: logger,
}
db.Set(proc)
mq.Publish(proc)
logger.Info("sending new process to message queue", slog.String("url", proc.Url))
slog.Info("sending new process to message queue", slog.String("url", proc.Url))
return cmd.Wait()
}

View File

@@ -3,6 +3,7 @@ package internal
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -13,15 +14,12 @@ import (
"sync"
"syscall"
"log"
"os"
"os/exec"
"strings"
"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/rx"
)
const template = `download:
@@ -40,14 +38,14 @@ const (
// Process descriptor
type Process struct {
Id string
Url string
Params []string
Info DownloadInfo
Progress DownloadProgress
Output DownloadOutput
proc *os.Process
Logger *slog.Logger
Id string
Url string
Livestream bool
Params []string
Info DownloadInfo
Progress DownloadProgress
Output DownloadOutput
proc *os.Process
}
// Starts spawns/forks a new yt-dlp process and parse its stdout.
@@ -102,81 +100,102 @@ func (p *Process) Start() {
params := append(baseParams, p.Params...)
// ----------------- main block ----------------- //
slog.Info("requesting download", slog.String("url", p.Url), slog.Any("params", params))
cmd := exec.Command(config.Instance().DownloaderPath, params...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
r, err := cmd.StdoutPipe()
stdout, err := cmd.StdoutPipe()
if err != nil {
p.Logger.Error(
"failed to connect to stdout",
slog.String("err", err.Error()),
)
slog.Error("failed to get a stdout pipe", slog.Any("err", err))
panic(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
slog.Error("failed to get a stderr pipe", slog.Any("err", err))
panic(err)
}
if err := cmd.Start(); err != nil {
p.Logger.Error(
"failed to start yt-dlp process",
slog.String("err", err.Error()),
)
slog.Error("failed to start yt-dlp process", slog.Any("err", err))
panic(err)
}
p.proc = cmd.Process
// --------------- progress block --------------- //
var (
sourceChan = make(chan []byte)
doneChan = make(chan struct{})
)
ctx, cancel := context.WithCancel(context.Background())
defer func() {
stdout.Close()
p.Complete()
cancel()
}()
// 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)
logs := make(chan []byte)
go produceLogs(stdout, logs)
go p.consumeLogs(ctx, logs)
go p.detectYtDlpErrors(stderr)
cmd.Wait()
}
func produceLogs(r io.Reader, logs chan<- []byte) {
go func() {
scan := bufio.NewScanner(r)
scanner := bufio.NewScanner(r)
defer func() {
r.Close()
p.Complete()
doneChan <- struct{}{}
close(sourceChan)
close(doneChan)
}()
for scan.Scan() {
sourceChan <- scan.Bytes()
for scanner.Scan() {
logs <- scanner.Bytes()
}
}()
}
// Slows down the unmarshal operation to every 500ms
go func() {
rx.Sample(time.Millisecond*500, sourceChan, doneChan, func(event []byte) {
var progress ProgressTemplate
if err := json.Unmarshal(event, &progress); err != nil {
return
}
p.Progress = DownloadProgress{
Status: StatusDownloading,
Percentage: progress.Percentage,
Speed: progress.Speed,
ETA: progress.Eta,
}
p.Logger.Info("progress",
func (p *Process) consumeLogs(ctx context.Context, logs <-chan []byte) {
for {
select {
case <-ctx.Done():
slog.Info("detaching from yt-dlp stdout",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("percentage", progress.Percentage),
)
})
}()
return
case entry := <-logs:
p.parseLogEntry(entry)
}
}
}
// ------------- end progress block ------------- //
cmd.Wait()
func (p *Process) parseLogEntry(entry []byte) {
var progress ProgressTemplate
if err := json.Unmarshal(entry, &progress); err != nil {
return
}
p.Progress = DownloadProgress{
Status: StatusDownloading,
Percentage: progress.Percentage,
Speed: progress.Speed,
ETA: progress.Eta,
}
slog.Info("progress",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("percentage", progress.Percentage),
)
}
func (p *Process) detectYtDlpErrors(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
slog.Error("yt-dlp process error",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", scanner.Text()),
)
}
}
// Keep process in the memoryDB but marks it as complete
@@ -190,7 +209,7 @@ func (p *Process) Complete() {
ETA: 0,
}
p.Logger.Info("finished",
slog.Info("finished",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
)
@@ -221,16 +240,23 @@ func (p *Process) Kill() error {
}
// Returns the available format for this URL
//
// TODO: Move out from process.go
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
func (p *Process) GetFormats() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output()
if err != nil {
p.Logger.Error("failed to retrieve metadata", slog.String("err", err.Error()))
slog.Error("failed to retrieve metadata", slog.String("err", err.Error()))
return DownloadFormats{}, err
}
slog.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", p.Url),
)
info := DownloadFormats{URL: p.Url}
best := Format{}
@@ -241,23 +267,10 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
wg.Add(2)
log.Println(
cli.BgRed, "Metadata", cli.Reset,
cli.BgBlue, "Formats", cli.Reset,
p.Url,
)
p.Logger.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", p.Url),
)
go func() {
decodingError = json.Unmarshal(stdout, &info)
wg.Done()
}()
go func() {
decodingError = json.Unmarshal(stdout, &best)
wg.Done()
@@ -308,7 +321,7 @@ func (p *Process) SetMetadata() error {
stdout, err := cmd.StdoutPipe()
if err != nil {
p.Logger.Error("failed to connect to stdout",
slog.Error("failed to connect to stdout",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", err.Error()),
@@ -318,7 +331,7 @@ func (p *Process) SetMetadata() error {
stderr, err := cmd.StderrPipe()
if err != nil {
p.Logger.Error("failed to connect to stderr",
slog.Error("failed to connect to stderr",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
slog.String("err", err.Error()),
@@ -341,7 +354,7 @@ func (p *Process) SetMetadata() error {
io.Copy(&bufferedStderr, stderr)
}()
p.Logger.Info("retrieving metadata",
slog.Info("retrieving metadata",
slog.String("id", p.getShortId()),
slog.String("url", p.Url),
)

View File

@@ -11,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
)
var upgrader = websocket.Upgrader{
@@ -77,6 +78,9 @@ func ApplyRouter() func(chi.Router) {
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Get("/ws", webSocket)
r.Get("/sse", sse)
}

37
server/openid/config.go Normal file
View File

@@ -0,0 +1,37 @@
package openid
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"golang.org/x/oauth2"
)
var (
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
)
func Configure() {
if !config.Instance().UseOpenId {
return
}
provider, err := oidc.NewProvider(context.Background(), config.Instance().OpenIdProviderURL)
if err != nil {
panic(err)
}
oauth2Config = oauth2.Config{
ClientID: config.Instance().OpenIdClientId,
ClientSecret: config.Instance().OpenIdClientSecret,
RedirectURL: config.Instance().OpenIdRedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
verifier = provider.Verifier(&oidc.Config{
ClientID: config.Instance().OpenIdClientId,
})
}

178
server/openid/handler.go Normal file
View File

@@ -0,0 +1,178 @@
package openid
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid"
"golang.org/x/oauth2"
)
type OAuth2SuccessResponse struct {
OAuth2Token *oauth2.Token
IDTokenClaims *json.RawMessage
}
// var cookieMaxAge = int(time.Hour * 24 * 30) XXX: overflows on 32 bit architectures.
func Login(w http.ResponseWriter, r *http.Request) {
state := uuid.NewString()
nonceBytes := make([]byte, 16)
rand.Read(nonceBytes)
nonce := hex.EncodeToString(nonceBytes)
http.SetCookie(w, &http.Cookie{
Name: "state",
Value: state,
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
// MaxAge: cookieMaxAge,
Expires: time.Now().Add(time.Hour * 24 * 30), // XXX: change to MaxAge
})
http.SetCookie(w, &http.Cookie{
Name: "nonce",
Value: nonce,
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
// MaxAge: cookieMaxAge,
Expires: time.Now().Add(time.Hour * 24 * 30), // XXX: change to MaxAge
})
http.Redirect(w, r, oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
}
func doAuthentification(r *http.Request, setCookieCallback func(t *oauth2.Token)) (*OAuth2SuccessResponse, error) {
state, err := r.Cookie("state")
if err != nil {
return nil, err
}
if r.URL.Query().Get("state") != state.Value {
return nil, errors.New("auth state does not match")
}
oauth2Token, err := oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
return nil, err
}
rawToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return nil, errors.New("openid field \"id_token\" not found in oauth2 token")
}
idToken, err := verifier.Verify(r.Context(), rawToken)
if err != nil {
return nil, err
}
nonce, err := r.Cookie("nonce")
if err != nil {
return nil, err
}
if idToken.Nonce != nonce.Value {
return nil, errors.New("auth nonce does not match")
}
setCookieCallback(oauth2Token)
// redact
oauth2Token.AccessToken = "*REDACTED*"
res := OAuth2SuccessResponse{
oauth2Token,
&json.RawMessage{},
}
if err := idToken.Claims(&res.IDTokenClaims); err != nil {
return nil, err
}
return &res, nil
}
func SingIn(w http.ResponseWriter, r *http.Request) {
_, err := doAuthentification(r, func(t *oauth2.Token) {
idToken, _ := t.Extra("id_token").(string)
http.SetCookie(w, &http.Cookie{
Name: "oid-token",
Value: idToken,
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
// MaxAge: int(time.Hour * 24 * 30), XXX: overflows on 32 bit architectures.
})
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Write([]byte("Login succesfully, you may now close this window and refresh yt-dlp-webui."))
}
func Refresh(w http.ResponseWriter, r *http.Request) {
refreshToken := r.URL.Query().Get("refresh-token")
ts := oauth2Config.TokenSource(r.Context(), &oauth2.Token{RefreshToken: refreshToken})
token, err := ts.Token()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oid-token",
Value: token.AccessToken,
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
// MaxAge: int(time.Hour * 24 * 30), XXX: overflows on 32 bit architectures.
})
token.AccessToken = "*redacted*"
if err := json.NewEncoder(w).Encode(token); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "oid-token",
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
MaxAge: -1,
})
http.SetCookie(w, &http.Cookie{
Name: "state",
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
MaxAge: -1,
})
http.SetCookie(w, &http.Cookie{
Name: "nonce",
HttpOnly: true,
Path: "/",
Secure: r.TLS != nil,
MaxAge: -1,
})
}

View File

@@ -0,0 +1,20 @@
package openid
import "net/http"
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, err := r.Cookie("oid-token")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err := verifier.Verify(r.Context(), token.Value); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -2,14 +2,12 @@ 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
DB *sql.DB
MDB *internal.MemoryDB
MQ *internal.MessageQueue
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
)
func Container(args *ContainerArgs) *Handler {
@@ -21,10 +22,17 @@ func ApplyRouter(args *ContainerArgs) func(chi.Router) {
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Post("/exec", h.Exec())
r.Post("/execPlaylist", h.ExecPlaylist())
r.Post("/execLivestream", h.ExecLivestream())
r.Get("/running", h.Running())
r.Get("/version", h.GetVersion())
r.Get("/cookies", h.GetCookies())
r.Post("/cookies", h.SetCookies())
r.Delete("/cookies", h.DeleteCookies())
r.Post("/template", h.AddTemplate())
r.Get("/template/all", h.GetTemplates())
r.Delete("/template/{id}", h.DeleteTemplate())

View File

@@ -41,6 +41,51 @@ func (h *Handler) Exec() http.HandlerFunc {
}
}
func (h *Handler) ExecPlaylist() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
var req internal.DownloadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
err := h.service.ExecPlaylist(req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode("ok"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) ExecLivestream() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
var req internal.DownloadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
h.service.ExecLivestream(req)
err := json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) Running() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
@@ -60,6 +105,27 @@ func (h *Handler) Running() http.HandlerFunc {
}
}
func (h *Handler) GetCookies() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
cookies, err := h.service.GetCookies(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
res := &internal.SetCookiesRequest{
Cookies: string(cookies),
}
if err := json.NewEncoder(w).Encode(res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func (h *Handler) SetCookies() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
@@ -87,6 +153,23 @@ func (h *Handler) SetCookies() http.HandlerFunc {
}
}
func (h *Handler) DeleteCookies() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
err := h.service.SetCookies(r.Context(), "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode("ok")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (h *Handler) AddTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()

View File

@@ -15,10 +15,9 @@ var (
func ProvideService(args *ContainerArgs) *Service {
serviceOnce.Do(func() {
service = &Service{
mdb: args.MDB,
db: args.DB,
mq: args.MQ,
logger: args.Logger,
mdb: args.MDB,
db: args.DB,
mq: args.MQ,
}
})
return service

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"errors"
"log/slog"
"io"
"os"
"os/exec"
"time"
@@ -12,13 +12,14 @@ import (
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
)
type Service struct {
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
logger *slog.Logger
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
lm *livestream.Monitor
}
func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
@@ -29,7 +30,6 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
Path: req.Path,
Filename: req.Rename,
},
Logger: s.logger,
}
id := s.mdb.Set(p)
@@ -38,15 +38,39 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
return id, nil
}
func (s *Service) ExecPlaylist(req internal.DownloadRequest) error {
return internal.PlaylistDetect(req, s.mq, s.mdb)
}
func (s *Service) ExecLivestream(req internal.DownloadRequest) {
s.lm.Add(req.URL)
}
func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, error) {
select {
case <-ctx.Done():
return nil, errors.New("context cancelled")
return nil, context.Canceled
default:
return s.mdb.All(), nil
}
}
func (s *Service) GetCookies(ctx context.Context) ([]byte, error) {
fd, err := os.Open("cookies.txt")
if err != nil {
return nil, err
}
defer fd.Close()
cookies, err := io.ReadAll(fd)
if err != nil {
return nil, err
}
return cookies, nil
}
func (s *Service) SetCookies(ctx context.Context, cookies string) error {
fd, err := os.Create("cookies.txt")
if err != nil {

View File

@@ -1,24 +1,20 @@
package rpc
import (
"log/slog"
"github.com/go-chi/chi/v5"
"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/livestream"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
)
// Dependency injection container.
func Container(
db *internal.MemoryDB,
mq *internal.MessageQueue,
logger *slog.Logger,
) *Service {
func Container(db *internal.MemoryDB, mq *internal.MessageQueue, lm *livestream.Monitor) *Service {
return &Service{
db: db,
mq: mq,
logger: logger,
db: db,
mq: mq,
lm: lm,
}
}
@@ -28,6 +24,9 @@ func ApplyRouter() func(chi.Router) {
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Get("/ws", WebSocket)
r.Post("/http", Post)
}

View File

@@ -5,14 +5,15 @@ import (
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater"
)
type Service struct {
db *internal.MemoryDB
mq *internal.MessageQueue
logger *slog.Logger
db *internal.MemoryDB
mq *internal.MessageQueue
lm *livestream.Monitor
}
type Running []internal.ProcessResponse
@@ -36,7 +37,6 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
Path: args.Path,
Filename: args.Rename,
},
Logger: s.logger,
}
s.db.Set(p)
@@ -49,7 +49,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
// Exec spawns a Process.
// The result of the execution is the newly spawned process Id.
func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error {
err := internal.PlaylistDetect(args, s.mq, s.db, s.logger)
err := internal.PlaylistDetect(args, s.mq, s.db)
if err != nil {
return err
}
@@ -58,6 +58,38 @@ func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) er
return nil
}
// TODO: docs
func (s *Service) ExecLivestream(args internal.DownloadRequest, result *string) error {
s.lm.Add(args.URL)
*result = args.URL
return nil
}
// TODO: docs
func (s *Service) ProgressLivestream(args NoArgs, result *livestream.LiveStreamStatus) error {
*result = s.lm.Status()
return nil
}
// TODO: docs
func (s *Service) KillLivestream(args string, result *struct{}) error {
slog.Info("killing livestream", slog.String("url", args))
err := s.lm.Remove(args)
if err != nil {
slog.Error("failed killing livestream", slog.String("url", args), slog.Any("err", err))
return err
}
return nil
}
// TODO: docs
func (s *Service) KillAllLivestream(args NoArgs, result *struct{}) error {
return s.lm.RemoveAll()
}
// Progess retrieves the Progress of a specific Process given its Id
func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error {
proc, err := s.db.Get(args.Id)
@@ -73,9 +105,9 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
var (
err error
p = internal.Process{Url: args.URL, Logger: s.logger}
p = internal.Process{Url: args.URL}
)
*meta, err = p.GetFormatsSync()
*meta, err = p.GetFormats()
return err
}
@@ -93,7 +125,7 @@ func (s *Service) Running(args NoArgs, running *Running) error {
// Kill kills a process given its id and remove it from the memoryDB
func (s *Service) Kill(args string, killed *string) error {
s.logger.Info("Trying killing process with id", slog.String("id", args))
slog.Info("Trying killing process with id", slog.String("id", args))
proc, err := s.db.Get(args)
if err != nil {
@@ -105,12 +137,12 @@ func (s *Service) Kill(args string, killed *string) error {
}
if err := proc.Kill(); err != nil {
s.logger.Info("failed killing process", slog.String("id", proc.Id), slog.Any("err", err))
slog.Info("failed killing process", slog.String("id", proc.Id), slog.Any("err", err))
return err
}
s.db.Delete(proc.Id)
s.logger.Info("succesfully killed process", slog.String("id", proc.Id))
slog.Info("succesfully killed process", slog.String("id", proc.Id))
return nil
}
@@ -118,7 +150,7 @@ func (s *Service) Kill(args string, killed *string) error {
// KillAll kills all process unconditionally and removes them from
// the memory db
func (s *Service) KillAll(args NoArgs, killed *string) error {
s.logger.Info("Killing all spawned processes")
slog.Info("Killing all spawned processes")
var (
keys = s.db.Keys()
@@ -140,7 +172,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
}
if err := removeFunc(proc); err != nil {
s.logger.Info(
slog.Info(
"failed killing process",
slog.String("id", proc.Id),
slog.Any("err", err),
@@ -148,7 +180,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
continue
}
s.logger.Info("succesfully killed process", slog.String("id", proc.Id))
slog.Info("succesfully killed process", slog.String("id", proc.Id))
}
return nil
@@ -156,7 +188,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
// Remove a process from the db rendering it unusable if active
func (s *Service) Clear(args string, killed *string) error {
s.logger.Info("Clearing process with id", slog.String("id", args))
slog.Info("Clearing process with id", slog.String("id", args))
s.db.Delete(args)
return nil
}
@@ -190,16 +222,16 @@ func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
// Updates the yt-dlp binary using its builtin function
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
s.logger.Info("Updating yt-dlp executable to the latest release")
slog.Info("Updating yt-dlp executable to the latest release")
if err := updater.UpdateExecutable(); err != nil {
s.logger.Error("Failed updating yt-dlp")
slog.Error("Failed updating yt-dlp")
*updated = false
return err
}
*updated = true
s.logger.Info("Succesfully updated yt-dlp")
slog.Info("Succesfully updated yt-dlp")
return nil
}

View File

@@ -1,31 +0,0 @@
package rx
import "time"
// ReactiveX inspired sample function.
//
// Debounce emits the most recently emitted value from the source
// 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)) {
var (
item []byte
ticker = time.NewTicker(span)
)
for {
select {
case <-ticker.C:
if item != nil {
fn(item)
}
case <-source:
item = <-source
case <-done:
ticker.Stop()
return
}
}
}

View File

@@ -1,3 +1,4 @@
// a stupid package name...
package server
import (
@@ -22,8 +23,10 @@ import (
"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/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
@@ -43,22 +46,24 @@ type RunConfig struct {
type serverConfig struct {
frontend fs.FS
swagger fs.FS
logger *slog.Logger
host string
port int
mdb *internal.MemoryDB
db *sql.DB
mq *internal.MessageQueue
lm *livestream.Monitor
}
func RunBlocking(cfg *RunConfig) {
var mdb internal.MemoryDB
mdb := internal.NewMemoryDB()
// ---- LOGGING ---------------------------------------------------
logWriters := []io.Writer{
os.Stdout,
logging.NewObservableLogger(),
logging.NewObservableLogger(), // for web-ui
}
// file based logging
if cfg.FileLogging {
logger, err := logging.NewRotableLogger(cfg.LogFile)
if err != nil {
@@ -75,46 +80,54 @@ func RunBlocking(cfg *RunConfig) {
logWriters = append(logWriters, logger)
}
logger := slog.New(
slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{}),
)
logger := slog.New(slog.NewTextHandler(io.MultiWriter(logWriters...), &slog.HandlerOptions{
Level: slog.LevelInfo, // TODO: detect when launched in debug mode -> slog.LevelDebug
}))
// make the new logger the default one with all the new writers
slog.SetDefault(logger)
// ----------------------------------------------------------------
db, err := sql.Open("sqlite", cfg.DBPath)
if err != nil {
logger.Error("failed to open database", slog.String("err", err.Error()))
slog.Error("failed to open database", slog.String("err", err.Error()))
}
if err := dbutil.AutoMigrate(context.Background(), db); err != nil {
logger.Error("failed to init database", slog.String("err", err.Error()))
if err := dbutil.Migrate(context.Background(), db); err != nil {
slog.Error("failed to init database", slog.String("err", err.Error()))
}
mq, err := internal.NewMessageQueue(logger)
mq, err := internal.NewMessageQueue()
if err != nil {
panic(err)
}
mq.SetupConsumers()
go mdb.Restore(mq)
go mdb.Restore(mq, logger)
lm := livestream.NewMonitor(mq, mdb)
go lm.Schedule()
go lm.Restore()
srv := newServer(serverConfig{
frontend: cfg.App,
swagger: cfg.Swagger,
logger: logger,
host: cfg.Host,
port: cfg.Port,
mdb: &mdb,
mdb: mdb,
mq: mq,
db: db,
lm: lm,
})
go gracefulShutdown(srv, &mdb)
go autoPersist(time.Minute*5, &mdb, logger)
go gracefulShutdown(srv, mdb)
go autoPersist(time.Minute*5, mdb)
var (
network = "tcp"
address = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
)
// support unix sockets
if strings.HasPrefix(cfg.Host, "/") {
network = "unix"
address = cfg.Host
@@ -122,19 +135,26 @@ func RunBlocking(cfg *RunConfig) {
listener, err := net.Listen(network, address)
if err != nil {
logger.Error("failed to listen", slog.String("err", err.Error()))
slog.Error("failed to listen", slog.String("err", err.Error()))
return
}
logger.Info("yt-dlp-webui started", slog.String("address", address))
slog.Info("yt-dlp-webui started", slog.String("address", address))
if err := srv.Serve(listener); err != nil {
logger.Warn("http server stopped", slog.String("err", err.Error()))
slog.Warn("http server stopped", slog.String("err", err.Error()))
}
}
func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.mdb, c.mq, c.logger)
go func() {
for {
c.lm.Persist()
time.Sleep(time.Minute * 5)
}
}()
service := ytdlpRPC.Container(c.mdb, c.mq, c.lm)
rpc.Register(service)
r := chi.NewRouter()
@@ -168,6 +188,9 @@ func newServer(c serverConfig) *http.Server {
if config.Instance().RequireAuth {
r.Use(middlewares.Authenticated)
}
if config.Instance().UseOpenId {
r.Use(openid.Middleware)
}
r.Post("/downloaded", handlers.ListDownloaded)
r.Post("/delete", handlers.DeleteFile)
r.Get("/d/{id}", handlers.DownloadFile)
@@ -179,6 +202,12 @@ func newServer(c serverConfig) *http.Server {
r.Route("/auth", func(r chi.Router) {
r.Post("/login", handlers.Login)
r.Get("/logout", handlers.Logout)
r.Route("/openid", func(r chi.Router) {
r.Get("/login", openid.Login)
r.Get("/signin", openid.SingIn)
r.Get("/logout", openid.Logout)
})
})
// RPC handlers
@@ -186,10 +215,9 @@ func newServer(c serverConfig) *http.Server {
// REST API handlers
r.Route("/api/v1", rest.ApplyRouter(&rest.ContainerArgs{
DB: c.db,
MDB: c.mdb,
MQ: c.mq,
Logger: c.logger,
DB: c.db,
MDB: c.mdb,
MQ: c.mq,
}))
// Logging
@@ -211,21 +239,22 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
defer func() {
db.Persist()
stop()
srv.Shutdown(context.Background())
}()
}()
}
func autoPersist(d time.Duration, db *internal.MemoryDB, logger *slog.Logger) {
func autoPersist(d time.Duration, db *internal.MemoryDB) {
for {
if err := db.Persist(); err != nil {
logger.Info(
slog.Warn(
"failed to persisted session",
slog.String("err", err.Error()),
)
}
logger.Info("sucessfully persisted session")
slog.Debug("sucessfully persisted session")
time.Sleep(d)
}
}