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:
Luna 2026-05-31 06:08:25 -07:00
parent 4f15ab873f
commit 2696a51cc8
7 changed files with 439 additions and 4 deletions

View file

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