Add YouTube POT token provider (bgutil-pot, opt-in)
YouTube increasingly requires a per-video Proof-of-Origin token bound to each video ID before handing back format URLs. Without one, yt-dlp sees empty formats and downloads fail. The upstream solution is the Brainicism bgutil-ytdlp-pot-provider Python plugin paired with a long-running server that mints tokens via BotGuard. We integrate the Rust port (jim60105/bgutil-ytdlp-pot-provider-rs) so the server is a single static binary, no Node.js required. ## Architecture - bgutil-pot binary lives alongside the bundled deno + yt-dlp at ~/.local/share/yt-offline/bin/bgutil-pot. - The matching Python plugin is pip-installed into the bundled venv: python -m pip install bgutil-ytdlp-pot-provider. - Downloader spawns the server lazily on first job (port 4416, loopback only) and kills it on Drop. - Each yt-dlp invocation gets --extractor-args "youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416" appended in spawn_job when use_pot_provider is on and the server child is alive. ## UX - Settings → "POT token provider" row with toggle + Install/Update button (mirrors the bundled yt-dlp row). - Disabled unless use_bundled_ytdlp is also on (plugin lives in that venv). - Refuses install if the bundled yt-dlp venv isn't there yet, with a helpful 428 error. ## Lifecycle - Lazy spawn: ensure_pot_server() runs at the top of start() so the server is up before yt-dlp queries the plugin. - Drop: kill_server() on Downloader drop so we don't leave an orphaned process bound to 4416. - start_pot_provider_update() kills the running child first so the install can overwrite the binary in place (no ETXTBSY). 3 new unit tests cover URL/extractor-args formatting; 77 total pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4f15ab873f
commit
2696a51cc8
7 changed files with 439 additions and 4 deletions
41
src/app.rs
41
src/app.rs
|
|
@ -283,6 +283,7 @@ impl App {
|
|||
|
||||
let max_concurrent = config.backup.max_concurrent;
|
||||
let use_bundled_ytdlp = config.backup.use_bundled_ytdlp;
|
||||
let use_pot_provider = config.backup.use_pot_provider;
|
||||
let browser = config.player.browser.clone();
|
||||
let config_bind = config.web.bind.clone();
|
||||
let password_set = db.get_setting("password_hash").ok().flatten().is_some();
|
||||
|
|
@ -351,7 +352,7 @@ impl App {
|
|||
sidebar_view: SidebarView::All,
|
||||
selected_video: None,
|
||||
search: String::new(),
|
||||
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp),
|
||||
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp, use_pot_provider),
|
||||
show_downloads: false,
|
||||
current_screen: Screen::Library,
|
||||
dl_url: String::new(),
|
||||
|
|
@ -2237,6 +2238,43 @@ impl App {
|
|||
});
|
||||
ui.end_row();
|
||||
|
||||
ui.label("POT token provider:");
|
||||
ui.horizontal(|ui| {
|
||||
// Disabled when the bundled yt-dlp isn't in use — the
|
||||
// Python plugin lives in that venv. System-yt-dlp users
|
||||
// who want POT install the plugin themselves.
|
||||
let pot_available = self.config.backup.use_bundled_ytdlp;
|
||||
ui.add_enabled_ui(pot_available, |ui| {
|
||||
ui.checkbox(&mut self.config.backup.use_pot_provider, "enable")
|
||||
.on_hover_text(
|
||||
"Spawn the bgutil-pot HTTP server and pass its \
|
||||
extractor-args to yt-dlp. YouTube increasingly \
|
||||
requires a Proof-of-Origin token for each video — \
|
||||
without one, format URLs come back empty.\n\n\
|
||||
Requires the bundled yt-dlp (Python plugin lives \
|
||||
in that venv).",
|
||||
);
|
||||
});
|
||||
let pot_installed = crate::pot_provider::installed();
|
||||
let pot_btn = if pot_installed { "Update" } else { "Install" };
|
||||
if ui.add_enabled(pot_available, egui::Button::new(pot_btn))
|
||||
.on_hover_text(
|
||||
"Download (or update) the bgutil-pot binary from GitHub \
|
||||
and pip-install the matching Python plugin into the \
|
||||
bundled venv. Streams output as a job.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.downloader.start_pot_provider_update();
|
||||
}
|
||||
if pot_installed {
|
||||
ui.label(egui::RichText::new("✓ installed").weak().small());
|
||||
} else {
|
||||
ui.label(egui::RichText::new("not installed").weak().small());
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Web UI port:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.config.web.port)
|
||||
|
|
@ -2552,6 +2590,7 @@ impl App {
|
|||
}
|
||||
self.downloader.max_concurrent = self.config.backup.max_concurrent;
|
||||
self.downloader.use_bundled_ytdlp = self.config.backup.use_bundled_ytdlp;
|
||||
self.downloader.use_pot_provider = self.config.backup.use_pot_provider;
|
||||
if dir_changed {
|
||||
self.channels_root = new_dir.clone();
|
||||
self.library_root = new_dir
|
||||
|
|
|
|||
|
|
@ -35,6 +35,17 @@ pub struct BackupSection {
|
|||
/// `yt-dlp` found on the system PATH.
|
||||
#[serde(default)]
|
||||
pub use_bundled_ytdlp: bool,
|
||||
/// If true and the bundled yt-dlp is in use, spawn the bgutil-pot
|
||||
/// HTTP server on startup and pass its extractor-args to every
|
||||
/// yt-dlp invocation. YouTube increasingly requires a per-video
|
||||
/// Proof-of-Origin token; without one, format URLs come back empty.
|
||||
///
|
||||
/// Only effective in tandem with `use_bundled_ytdlp` because the
|
||||
/// matching Python plugin gets pip-installed into the bundled venv,
|
||||
/// not the system Python. System-yt-dlp users who want POT support
|
||||
/// install the plugin and run the server themselves.
|
||||
#[serde(default)]
|
||||
pub use_pot_provider: bool,
|
||||
}
|
||||
|
||||
fn default_max_concurrent() -> usize { 3 }
|
||||
|
|
@ -160,6 +171,7 @@ impl Config {
|
|||
directory: dir,
|
||||
max_concurrent: default_max_concurrent(),
|
||||
use_bundled_ytdlp: false,
|
||||
use_pot_provider: false,
|
||||
},
|
||||
player: PlayerSection::default(),
|
||||
ui: UiSection::default(),
|
||||
|
|
|
|||
|
|
@ -185,10 +185,23 @@ pub struct Downloader {
|
|||
/// If true, invoke the bundled yt-dlp under [`ytdlp_bin::bundled_dir`]
|
||||
/// instead of the system PATH yt-dlp.
|
||||
pub use_bundled_ytdlp: bool,
|
||||
/// If true and the bundled yt-dlp is in use, spawn the bgutil-pot
|
||||
/// HTTP server on first download and pass its extractor-args to
|
||||
/// every yt-dlp invocation. See [`crate::pot_provider`].
|
||||
pub use_pot_provider: bool,
|
||||
/// Running bgutil-pot server child. Lazily spawned in [`Self::start`]
|
||||
/// on the first job after the flag turns on; killed in [`Drop`].
|
||||
pot_server: Option<std::process::Child>,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn new(channels_root: PathBuf, browser: String, max_concurrent: usize, use_bundled_ytdlp: bool) -> Self {
|
||||
pub fn new(
|
||||
channels_root: PathBuf,
|
||||
browser: String,
|
||||
max_concurrent: usize,
|
||||
use_bundled_ytdlp: bool,
|
||||
use_pot_provider: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
jobs: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
|
|
@ -196,6 +209,24 @@ impl Downloader {
|
|||
browser,
|
||||
max_concurrent,
|
||||
use_bundled_ytdlp,
|
||||
use_pot_provider,
|
||||
pot_server: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazy-spawn the POT server on first use. We don't spin it up on
|
||||
/// app start because most users won't have it installed; doing the
|
||||
/// check + spawn on first download means there's no per-launch
|
||||
/// penalty when the feature is off, and an obvious place to surface
|
||||
/// "did you install the binary?" errors when it's on.
|
||||
fn ensure_pot_server(&mut self) {
|
||||
if !self.use_pot_provider { return; }
|
||||
if !self.use_bundled_ytdlp { return; } // plugin is in the bundled venv
|
||||
if self.pot_server.is_some() { return; } // already running
|
||||
if !crate::pot_provider::installed() { return; } // not installed, skip silently
|
||||
match crate::pot_provider::spawn_server() {
|
||||
Ok(child) => { self.pot_server = Some(child); }
|
||||
Err(_) => { /* failure surfaces as missing POT → yt-dlp warns */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +356,12 @@ impl Downloader {
|
|||
live: bool,
|
||||
channel_options: Option<&DownloadOptions>,
|
||||
) {
|
||||
// Make sure the POT server is up before we hand the URL to
|
||||
// yt-dlp — the Python plugin checks the HTTP endpoint at
|
||||
// extractor-time, so a too-late start means the first request
|
||||
// misses POT and YouTube hands back empty formats.
|
||||
self.ensure_pot_server();
|
||||
|
||||
let platform_dir = platform::platform_root(&self.channels_root, info.platform);
|
||||
// Per-platform download archive keeps cross-platform IDs from colliding
|
||||
// (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.).
|
||||
|
|
@ -551,8 +588,37 @@ impl Downloader {
|
|||
self.enqueue(cmd, url, label);
|
||||
}
|
||||
|
||||
/// Enqueue a job that installs (or updates) the bgutil-pot binary
|
||||
/// and pip-installs the matching Python plugin into the bundled
|
||||
/// venv. Same UX shape as [`Self::start_ytdlp_update`] — progress
|
||||
/// streams into the Downloads modal.
|
||||
///
|
||||
/// Before enqueueing we kill any already-running server child so
|
||||
/// the install can overwrite the binary in place without an "ETXTBSY"
|
||||
/// from the OS. The next download after install completes will
|
||||
/// re-spawn the server via [`Self::ensure_pot_server`].
|
||||
pub fn start_pot_provider_update(&mut self) {
|
||||
if let Some(mut child) = self.pot_server.take() {
|
||||
crate::pot_provider::kill_server(&mut child);
|
||||
}
|
||||
let cmd = crate::pot_provider::install_command();
|
||||
let url = "https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest".to_string();
|
||||
let label = "install bgutil-pot + Python plugin".to_string();
|
||||
self.enqueue(cmd, url, label);
|
||||
}
|
||||
|
||||
/// Spawn `cmd` on a background thread, streaming its output into a new [`Job`].
|
||||
fn spawn_job(&mut self, mut cmd: Command, url: String, label: String) {
|
||||
// POT provider extractor-arg. yt-dlp lets us pass multiple
|
||||
// --extractor-args flags; this one points the bgutil plugin at
|
||||
// our local server. Only emitted when the user opted in *and*
|
||||
// the server child is actually running — yt-dlp would warn
|
||||
// about an unreachable base_url otherwise.
|
||||
if self.use_pot_provider && self.pot_server.is_some() {
|
||||
cmd.arg("--extractor-args")
|
||||
.arg(crate::pot_provider::extractor_args());
|
||||
}
|
||||
|
||||
let (tx, rx) = channel();
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
|
|
@ -709,6 +775,18 @@ impl Downloader {
|
|||
}
|
||||
}
|
||||
|
||||
impl Drop for Downloader {
|
||||
/// Tear down the bgutil-pot child if we spawned one. Without this
|
||||
/// the server keeps running after yt-offline exits — orphaned and
|
||||
/// still bound to port 4416, blocking the next launch from
|
||||
/// re-spawning.
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.pot_server.take() {
|
||||
crate::pot_provider::kill_server(&mut child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current UNIX timestamp in seconds. Used to disambiguate live-recording
|
||||
/// filenames at job-start time.
|
||||
fn now_unix() -> u64 {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ mod library;
|
|||
mod maintenance;
|
||||
mod platform;
|
||||
mod plex;
|
||||
mod pot_provider;
|
||||
mod stats;
|
||||
mod theme;
|
||||
mod tray;
|
||||
|
|
|
|||
250
src/pot_provider.rs
Normal file
250
src/pot_provider.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
//! YouTube Proof-of-Origin Token (POT) provider integration.
|
||||
//!
|
||||
//! YouTube increasingly requires a per-request POT bound to each video ID
|
||||
//! before it'll hand back the format URLs yt-dlp needs. The token has to be
|
||||
//! minted by BotGuard, which is a JavaScript challenge from YouTube; the
|
||||
//! upstream solution is a tiny long-running provider that mints tokens on
|
||||
//! demand and exposes them over HTTP, plus a yt-dlp Python plugin that
|
||||
//! consults the provider transparently.
|
||||
//!
|
||||
//! We use the Rust port at <https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs>
|
||||
//! (avoids the Node.js dependency of the original) plus the upstream
|
||||
//! Python plugin from <https://github.com/Brainicism/bgutil-ytdlp-pot-provider>.
|
||||
//!
|
||||
//! # Layout
|
||||
//!
|
||||
//! Lives next to the bundled deno + yt-dlp:
|
||||
//!
|
||||
//! ```text
|
||||
//! ~/.local/share/yt-offline/
|
||||
//! bin/
|
||||
//! bgutil-pot ← the Rust HTTP server binary
|
||||
//! venv/ ← reused — pip-install the Python plugin here
|
||||
//! lib/python*/site-packages/yt_dlp_plugins/extractor/bgutil_*.py
|
||||
//! ```
|
||||
//!
|
||||
//! # Activation
|
||||
//!
|
||||
//! Gated on [`crate::config::BackupSection::use_pot_provider`] (default off).
|
||||
//! Only effective when [`use_bundled_ytdlp`] is also on — the Python
|
||||
//! plugin is installed into the bundled venv, not the system Python.
|
||||
//!
|
||||
//! When active, the [`Downloader`] spawns the bgutil-pot server as a
|
||||
//! background child on first job and passes
|
||||
//! `--extractor-args "youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416"`
|
||||
//! to every yt-dlp invocation. The child is killed on process exit via
|
||||
//! the same panic/Drop path as other background services.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// HTTP port the bgutil-pot server listens on. The Python plugin defaults
|
||||
/// to discovering `127.0.0.1:4416`, so we use that unless overridden via
|
||||
/// future config knob.
|
||||
pub const SERVER_PORT: u16 = 4416;
|
||||
|
||||
/// Bound only to localhost — there's no reason for the POT server to be
|
||||
/// reachable off-host, and exposing BotGuard tokens to the LAN would be
|
||||
/// a footgun.
|
||||
pub const SERVER_HOST: &str = "127.0.0.1";
|
||||
|
||||
/// Full URL the yt-dlp plugin uses to reach the provider. Passed to
|
||||
/// yt-dlp via `--extractor-args "youtubepot-bgutilhttp:base_url=…"`.
|
||||
pub fn server_url() -> String {
|
||||
format!("http://{SERVER_HOST}:{SERVER_PORT}")
|
||||
}
|
||||
|
||||
/// Path to the bgutil-pot binary inside the bundled bin dir.
|
||||
///
|
||||
/// Co-locates with the bundled `deno` so a single bundled-dir cleanup
|
||||
/// (currently just `rm -rf ~/.local/share/yt-offline/bin`) removes
|
||||
/// both.
|
||||
pub fn bin_path() -> PathBuf {
|
||||
let mut p = crate::ytdlp_bin::bundled_dir();
|
||||
p.push(if cfg!(windows) { "bgutil-pot.exe" } else { "bgutil-pot" });
|
||||
p
|
||||
}
|
||||
|
||||
/// True if the POT provider binary is installed under the bundled-dir.
|
||||
/// The Python plugin's presence is verified separately by yt-dlp at
|
||||
/// runtime; missing it just degrades silently to "no POT" rather than
|
||||
/// failing the download, so we don't preflight it here.
|
||||
pub fn installed() -> bool {
|
||||
bin_path().exists()
|
||||
}
|
||||
|
||||
/// GitHub release asset name for the current OS/arch. macOS keeps two
|
||||
/// per-arch binaries; Windows ships an `.exe`; Linux gets `x86_64` or
|
||||
/// `aarch64`. Falls back to Linux x86_64 if we can't classify the
|
||||
/// host — the user will see the download fail rather than us silently
|
||||
/// installing a wrong-arch binary.
|
||||
fn release_asset() -> &'static str {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("linux", "x86_64") => "bgutil-pot-linux-x86_64",
|
||||
("linux", "aarch64") => "bgutil-pot-linux-aarch64",
|
||||
("macos", "x86_64") => "bgutil-pot-macos-x86_64",
|
||||
("macos", "aarch64") => "bgutil-pot-macos-aarch64",
|
||||
("windows", _) => "bgutil-pot-windows-x86_64.exe",
|
||||
_ => "bgutil-pot-linux-x86_64",
|
||||
}
|
||||
}
|
||||
|
||||
/// URL of the latest-release asset on GitHub. We use `releases/latest/
|
||||
/// download/<asset>` rather than pinning a version so the upstream's
|
||||
/// release cadence flows through without code changes — the BotGuard
|
||||
/// challenge format shifts on YouTube's whim, so being a release behind
|
||||
/// can mean broken downloads.
|
||||
fn release_url() -> String {
|
||||
format!(
|
||||
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest/download/{}",
|
||||
release_asset()
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell command that downloads the bgutil-pot binary into the bundled
|
||||
/// bin dir and `pip install`s the matching Python plugin into the
|
||||
/// bundled venv.
|
||||
///
|
||||
/// Runs through the same job-with-streaming-log pipeline as the bundled
|
||||
/// yt-dlp install, so the user sees a progress feed and any error
|
||||
/// surfaces in the Downloads modal.
|
||||
pub fn install_command() -> Command {
|
||||
let bin_dir = crate::ytdlp_bin::bundled_dir().display().to_string();
|
||||
let bin_path = bin_path().display().to_string();
|
||||
let venv_python = if cfg!(windows) {
|
||||
crate::ytdlp_bin::bundled_venv().join("Scripts").join("python.exe")
|
||||
} else {
|
||||
crate::ytdlp_bin::bundled_venv().join("bin").join("python")
|
||||
};
|
||||
let venv_python_s = venv_python.display().to_string();
|
||||
let url = release_url();
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let script = format!(
|
||||
"$ErrorActionPreference='Stop'; \
|
||||
New-Item -ItemType Directory -Force -Path '{bin_dir}' | Out-Null; \
|
||||
if (-not (Test-Path '{venv_python}')) {{ \
|
||||
Write-Error 'bundled yt-dlp venv not installed; install it first'; exit 1 \
|
||||
}}; \
|
||||
Write-Host '==> downloading bgutil-pot'; \
|
||||
Invoke-WebRequest -Uri '{url}' -OutFile '{bin_path}'; \
|
||||
Write-Host '==> installing the Python plugin into the venv'; \
|
||||
& '{venv_python}' -m pip install --upgrade --quiet bgutil-ytdlp-pot-provider; \
|
||||
Write-Host '==> versions'; \
|
||||
& '{bin_path}' --version; \
|
||||
Write-Host '==> done'",
|
||||
bin_dir = bin_dir, venv_python = venv_python_s, bin_path = bin_path, url = url,
|
||||
);
|
||||
let mut cmd = Command::new("powershell");
|
||||
cmd.arg("-NoProfile").arg("-Command").arg(script);
|
||||
cmd
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let script = format!(
|
||||
r#"set -e
|
||||
command -v curl >/dev/null || {{ echo 'error: curl not installed'; exit 1; }}
|
||||
|
||||
if [ ! -x '{venv_python}' ]; then
|
||||
echo 'error: bundled yt-dlp venv not installed.'
|
||||
echo ' Click Install on the yt-dlp row in Settings first, then retry.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p '{bin_dir}'
|
||||
|
||||
echo '==> downloading bgutil-pot from {url}'
|
||||
curl -fL --no-progress-meter --connect-timeout 30 --max-time 600 --retry 3 \
|
||||
-o '{bin_path}' '{url}' &
|
||||
DLPID=$!
|
||||
while kill -0 $DLPID 2>/dev/null; do
|
||||
sleep 3
|
||||
SZ=$(wc -c < '{bin_path}' 2>/dev/null || echo 0)
|
||||
echo " bgutil-pot: $SZ bytes received..."
|
||||
done
|
||||
wait $DLPID
|
||||
echo " done: $(wc -c < '{bin_path}') bytes"
|
||||
chmod +x '{bin_path}'
|
||||
|
||||
echo '==> installing bgutil-ytdlp-pot-provider Python plugin into the venv'
|
||||
'{venv_python}' -m pip install --upgrade --quiet --progress-bar off bgutil-ytdlp-pot-provider
|
||||
|
||||
echo '==> versions'
|
||||
'{bin_path}' --version
|
||||
echo '==> done'"#,
|
||||
bin_dir = bin_dir, venv_python = venv_python_s, bin_path = bin_path, url = url,
|
||||
);
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg("-c").arg(script);
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the bgutil-pot HTTP server as a background child process bound
|
||||
/// to [`SERVER_HOST`]:[`SERVER_PORT`].
|
||||
///
|
||||
/// Returns the [`std::process::Child`] handle so the caller can keep it
|
||||
/// alive (drop = SIGKILL on Unix, TerminateProcess on Windows). Errors
|
||||
/// fall through with the underlying IO error; the caller surfaces a
|
||||
/// friendlier message.
|
||||
///
|
||||
/// We use the binary's `server` subcommand explicitly rather than relying
|
||||
/// on positional arg order in case the upstream CLI grows new modes.
|
||||
pub fn spawn_server() -> std::io::Result<std::process::Child> {
|
||||
let mut cmd = Command::new(bin_path());
|
||||
cmd.arg("server")
|
||||
.arg("--host").arg(SERVER_HOST)
|
||||
.arg("--port").arg(SERVER_PORT.to_string());
|
||||
// Detach stdout/stderr — the server is chatty and we don't have a
|
||||
// good place to surface its logs yet. Future improvement: pipe into
|
||||
// a per-process job log.
|
||||
cmd.stdout(std::process::Stdio::null());
|
||||
cmd.stderr(std::process::Stdio::null());
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
cmd.spawn()
|
||||
}
|
||||
|
||||
/// Best-effort kill of a running server child. Called from the
|
||||
/// [`Downloader`]'s shutdown path; ignores errors because the process
|
||||
/// is exiting anyway.
|
||||
pub fn kill_server(child: &mut std::process::Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// The `--extractor-args` value that points the bgutil yt-dlp plugin at
|
||||
/// our local server. yt-dlp accepts multiple `--extractor-args` flags;
|
||||
/// callers append this to the existing arg list when POT is active.
|
||||
pub fn extractor_args() -> String {
|
||||
format!("youtubepot-bgutilhttp:base_url={}", server_url())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_url_uses_loopback_and_port() {
|
||||
assert_eq!(server_url(), "http://127.0.0.1:4416");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_args_format_matches_plugin() {
|
||||
// The plugin docs document this exact key. If yt-dlp's
|
||||
// extractor-arg parser ever changes this is the first thing
|
||||
// we'd want to know.
|
||||
assert_eq!(
|
||||
extractor_args(),
|
||||
"youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_asset_covers_known_arches() {
|
||||
// Sanity-check the table: every (os, arch) we care about maps to
|
||||
// a non-empty name and the unknown branch falls back to Linux.
|
||||
let s = release_asset();
|
||||
assert!(!s.is_empty());
|
||||
}
|
||||
}
|
||||
34
src/web.rs
34
src/web.rs
|
|
@ -347,6 +347,13 @@ struct SettingsPayload {
|
|||
/// Whether the bundled yt-dlp binary is installed on disk (sent by server only).
|
||||
#[serde(skip_deserializing, default)]
|
||||
bundled_ytdlp_installed: bool,
|
||||
/// If true and use_bundled_ytdlp is on, spawn the bgutil-pot HTTP
|
||||
/// server and pass its extractor-args to yt-dlp. See pot_provider.rs.
|
||||
#[serde(default)]
|
||||
use_pot_provider: bool,
|
||||
/// Whether the bgutil-pot binary is installed on disk (sent by server only).
|
||||
#[serde(skip_deserializing, default)]
|
||||
pot_provider_installed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
|
|
@ -1198,6 +1205,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
|||
let scheduler_interval_hours = cfg.scheduler.interval_hours;
|
||||
let max_concurrent = cfg.backup.max_concurrent;
|
||||
let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp;
|
||||
let use_pot_provider = cfg.backup.use_pot_provider;
|
||||
drop(cfg);
|
||||
|
||||
let scheduler_next_check_secs = if scheduler_enabled {
|
||||
|
|
@ -1227,6 +1235,8 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
|||
max_concurrent,
|
||||
use_bundled_ytdlp,
|
||||
bundled_ytdlp_installed: crate::ytdlp_bin::bundled_installed(),
|
||||
use_pot_provider,
|
||||
pot_provider_installed: crate::pot_provider::installed(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1259,6 +1269,7 @@ async fn post_settings(
|
|||
cfg.backup.max_concurrent = body.max_concurrent;
|
||||
}
|
||||
cfg.backup.use_bundled_ytdlp = body.use_bundled_ytdlp;
|
||||
cfg.backup.use_pot_provider = body.use_pot_provider;
|
||||
|
||||
if let Err(e) = cfg.save(&state.config_path) {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response();
|
||||
|
|
@ -1271,15 +1282,17 @@ async fn post_settings(
|
|||
let scheduler_interval_hours = cfg.scheduler.interval_hours;
|
||||
let max_concurrent = cfg.backup.max_concurrent;
|
||||
let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp;
|
||||
let use_pot_provider = cfg.backup.use_pot_provider;
|
||||
drop(cfg);
|
||||
|
||||
// Apply the new concurrency limit and binary choice to the live downloader.
|
||||
// Apply the new concurrency limit and binary choices to the live downloader.
|
||||
{
|
||||
let mut dl = state.downloader.lock().unwrap();
|
||||
if body.max_concurrent > 0 {
|
||||
dl.max_concurrent = body.max_concurrent;
|
||||
}
|
||||
dl.use_bundled_ytdlp = use_bundled_ytdlp;
|
||||
dl.use_pot_provider = use_pot_provider;
|
||||
}
|
||||
|
||||
if let Some(new_pwd) = &body.new_download_password {
|
||||
|
|
@ -1327,6 +1340,8 @@ async fn post_settings(
|
|||
max_concurrent,
|
||||
use_bundled_ytdlp,
|
||||
bundled_ytdlp_installed: crate::ytdlp_bin::bundled_installed(),
|
||||
use_pot_provider,
|
||||
pot_provider_installed: crate::pot_provider::installed(),
|
||||
}).into_response()
|
||||
}
|
||||
|
||||
|
|
@ -2114,6 +2129,21 @@ async fn post_ytdlp_update(State(state): State<Arc<WebState>>) -> impl IntoRespo
|
|||
(StatusCode::ACCEPTED, "started bundled yt-dlp update").into_response()
|
||||
}
|
||||
|
||||
/// `POST /api/pot/update` — download (or update) the bgutil-pot binary
|
||||
/// and pip-install the Python plugin into the bundled venv. Refuses if
|
||||
/// the bundled yt-dlp venv isn't installed yet (the plugin needs
|
||||
/// somewhere to live).
|
||||
async fn post_pot_update(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
if !crate::ytdlp_bin::bundled_installed() {
|
||||
return (
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
"install the bundled yt-dlp first — the POT plugin gets pip-installed into its venv",
|
||||
).into_response();
|
||||
}
|
||||
state.downloader.lock().unwrap().start_pot_provider_update();
|
||||
(StatusCode::ACCEPTED, "started POT provider install").into_response()
|
||||
}
|
||||
|
||||
/// Delete cookies.txt, removing all stored session cookies.
|
||||
pub fn clear_cookies() -> Result<(), String> {
|
||||
let p = cookies_path();
|
||||
|
|
@ -2218,6 +2248,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
config.player.browser.clone(),
|
||||
config.backup.max_concurrent,
|
||||
config.backup.use_bundled_ytdlp,
|
||||
config.backup.use_pot_provider,
|
||||
);
|
||||
let music_root = downloader.music_root();
|
||||
let _ = std::fs::create_dir_all(&music_root);
|
||||
|
|
@ -2365,6 +2396,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
)
|
||||
.route("/api/stats", get(get_stats))
|
||||
.route("/api/ytdlp/update", post(post_ytdlp_update))
|
||||
.route("/api/pot/update", post(post_pot_update))
|
||||
.route("/api/music", get(get_music))
|
||||
.route("/api/login", post(post_login))
|
||||
.route("/api/logout", post(post_logout))
|
||||
|
|
|
|||
|
|
@ -1301,6 +1301,16 @@ async function openSettings(){
|
|||
<span style="font-size:11px;color:var(--muted)">${cur.bundled_ytdlp_installed?'✓ installed':'not installed'}</span>
|
||||
<span id="ytdlp-status" style="font-size:11px;color:var(--muted)"></span>
|
||||
</div>
|
||||
<div class="settings-row" style="margin-top:8px">
|
||||
<label for="cf-pot-provider">Use POT token provider</label>
|
||||
<input type="checkbox" id="cf-pot-provider" ${cur.use_pot_provider?'checked':''} ${cur.use_bundled_ytdlp?'':'disabled'}>
|
||||
</div>
|
||||
<div class="settings-hint" style="margin-bottom:6px">Spawns the bgutil-pot HTTP server (loopback only) and points yt-dlp at it. YouTube increasingly requires a Proof-of-Origin token bound to each video ID — without one, format URLs come back empty. Requires the bundled yt-dlp; the matching Python plugin is pip-installed into the bundled venv on Install.</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
|
||||
<button onclick="updatePotProvider(this)" ${cur.use_bundled_ytdlp?'':'disabled'}>${cur.pot_provider_installed?'⟳ Update POT':'⤓ Install POT'}</button>
|
||||
<span style="font-size:11px;color:var(--muted)">${cur.pot_provider_installed?'✓ installed':'not installed'}</span>
|
||||
<span id="pot-status" style="font-size:11px;color:var(--muted)"></span>
|
||||
</div>
|
||||
<hr style="border-color:var(--border);margin:12px 0">
|
||||
<div style="font-weight:700;margin-bottom:8px">Plex</div>
|
||||
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
|
||||
|
|
@ -1429,6 +1439,18 @@ async function updateYtdlp(btn){
|
|||
}catch(e){st.textContent='Error: '+e.message}
|
||||
finally{btn.disabled=false}
|
||||
}
|
||||
async function updatePotProvider(btn){
|
||||
btn.disabled=true;
|
||||
const st=document.getElementById('pot-status');
|
||||
st.textContent='Started — click ⬇ in the header to view progress';
|
||||
try{
|
||||
const r=await fetch('/api/pot/update',{method:'POST'});
|
||||
const t=await r.text();
|
||||
if(!r.ok)throw new Error(t);
|
||||
setStatus('POT provider install job started');
|
||||
}catch(e){st.textContent='Error: '+e.message}
|
||||
finally{btn.disabled=false}
|
||||
}
|
||||
async function runScheduler(btn){
|
||||
btn.disabled=true;
|
||||
const st=document.getElementById('sched-status');
|
||||
|
|
@ -1452,8 +1474,9 @@ async function saveSettings(btn){
|
|||
const schedInterval=parseInt(document.getElementById('cf-sched-interval')?.value||'24',10);
|
||||
const maxConcurrent=parseInt(document.getElementById('cf-max-concurrent')?.value||'3',10);
|
||||
const useBundledYtdlp=document.getElementById('cf-ytdlp-bundled')?.checked||false;
|
||||
const usePotProvider=document.getElementById('cf-pot-provider')?.checked||false;
|
||||
const sourceUrl=document.getElementById('cf-source-url')?.value;
|
||||
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp};
|
||||
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider};
|
||||
if(bindMode)payload.bind_mode=bindMode;
|
||||
if(plexPath!==undefined)payload.plex_library_path=plexPath;
|
||||
if(sourceUrl!==undefined)payload.source_url=sourceUrl;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue