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>
183 lines
6.3 KiB
Rust
183 lines
6.3 KiB
Rust
//! Application configuration loaded from `config.toml`.
|
|
//!
|
|
//! Each top-level section maps to a TOML table. Missing sections get sane
|
|
//! defaults so existing config files continue to work after upgrades.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Root configuration object, serialised from/to `config.toml`.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct Config {
|
|
pub backup: BackupSection,
|
|
#[serde(default)]
|
|
pub player: PlayerSection,
|
|
#[serde(default)]
|
|
pub ui: UiSection,
|
|
#[serde(default)]
|
|
pub scheduler: SchedulerSection,
|
|
#[serde(default)]
|
|
pub web: WebSection,
|
|
#[serde(default)]
|
|
pub plex: PlexSection,
|
|
}
|
|
|
|
/// `[backup]` table — where to store downloaded videos.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct BackupSection {
|
|
pub directory: PathBuf,
|
|
/// Maximum simultaneous yt-dlp processes. Extra downloads queue and start
|
|
/// automatically when a slot opens. Set to 0 for no limit (not recommended).
|
|
#[serde(default = "default_max_concurrent")]
|
|
pub max_concurrent: usize,
|
|
/// If true, use the bundled yt-dlp + deno binaries managed by yt-offline
|
|
/// (installed under `~/.local/share/yt-offline/bin/`). If false, use the
|
|
/// `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 }
|
|
|
|
/// `[player]` table — external player and browser cookie source.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct PlayerSection {
|
|
#[serde(default = "default_player")]
|
|
pub command: String,
|
|
#[serde(default = "default_browser")]
|
|
pub browser: String,
|
|
}
|
|
|
|
impl Default for PlayerSection {
|
|
fn default() -> Self {
|
|
Self { command: default_player(), browser: default_browser() }
|
|
}
|
|
}
|
|
|
|
/// `[ui]` table — egui desktop theme.
|
|
///
|
|
/// Available themes: `dark`, `light`, `dracula`, `trans`, `emo-nocturnal`, `emo-coffin`, `emo-scene-queen`.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct UiSection {
|
|
#[serde(default = "default_theme")]
|
|
pub theme: String,
|
|
/// When true, clicking the window close button hides the window
|
|
/// into the system tray instead of exiting. The tray menu's Quit
|
|
/// item (or Ctrl+Q) still terminates the app. Off by default so
|
|
/// users without a working SNI host don't get stuck in an invisible
|
|
/// hidden window.
|
|
#[serde(default)]
|
|
pub minimize_to_tray: bool,
|
|
}
|
|
|
|
impl Default for UiSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
theme: default_theme(),
|
|
minimize_to_tray: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `[scheduler]` table — periodic background download schedule.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct SchedulerSection {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_interval_hours")]
|
|
pub interval_hours: u32,
|
|
}
|
|
|
|
impl Default for SchedulerSection {
|
|
fn default() -> Self {
|
|
Self { enabled: false, interval_hours: default_interval_hours() }
|
|
}
|
|
}
|
|
|
|
/// `[plex]` table — Plex-compatible TV-show symlink library.
|
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
|
pub struct PlexSection {
|
|
/// Directory where the Plex symlink tree is written.
|
|
/// Leave unset to disable Plex library generation.
|
|
pub library_path: Option<PathBuf>,
|
|
}
|
|
|
|
/// `[web]` table — built-in HTTP server settings.
|
|
///
|
|
/// `source_url` is **required for AGPL §13 compliance**: set it to a URL
|
|
/// where the running source code can be obtained (e.g. your Codeberg repo).
|
|
/// It is shown as a "Source" link in the web UI footer.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct WebSection {
|
|
#[serde(default = "default_web_port")]
|
|
pub port: u16,
|
|
#[serde(default = "default_web_bind")]
|
|
/// Address to bind the HTTP server to. Defaults to `127.0.0.1` (localhost only).
|
|
/// Set to `0.0.0.0` to accept connections from any interface (not recommended).
|
|
pub bind: String,
|
|
#[serde(default)]
|
|
pub transcode: bool,
|
|
/// Public URL to the source repository, shown in the web UI per AGPL §13.
|
|
pub source_url: Option<String>,
|
|
}
|
|
|
|
impl Default for WebSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
port: default_web_port(),
|
|
bind: default_web_bind(),
|
|
transcode: false,
|
|
source_url: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_player() -> String { "mpv".to_string() }
|
|
fn default_browser() -> String { "firefox".to_string() }
|
|
fn default_theme() -> String { "dark".to_string() }
|
|
fn default_interval_hours() -> u32 { 24 }
|
|
fn default_web_port() -> u16 { 8080 }
|
|
fn default_web_bind() -> String { "127.0.0.1".to_string() }
|
|
|
|
impl Config {
|
|
/// Load and parse `config.toml` from `path`.
|
|
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let contents = std::fs::read_to_string(path)?;
|
|
toml::from_str(&contents).map_err(|e| e.into())
|
|
}
|
|
|
|
/// Serialise the config back to `path` in pretty TOML.
|
|
pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
let contents = toml::to_string_pretty(self)?;
|
|
std::fs::write(path, contents)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Construct a minimal default config pointing `backup.directory` at `dir`.
|
|
pub fn default_with_dir(dir: PathBuf) -> Self {
|
|
Self {
|
|
backup: BackupSection {
|
|
directory: dir,
|
|
max_concurrent: default_max_concurrent(),
|
|
use_bundled_ytdlp: false,
|
|
use_pot_provider: false,
|
|
},
|
|
player: PlayerSection::default(),
|
|
ui: UiSection::default(),
|
|
scheduler: SchedulerSection::default(),
|
|
web: WebSection::default(),
|
|
plex: PlexSection::default(),
|
|
}
|
|
}
|
|
}
|