diff --git a/src/app.rs b/src/app.rs index cc11dce..b24d436 100644 --- a/src/app.rs +++ b/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 diff --git a/src/config.rs b/src/config.rs index eac6d0c..bb05966 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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(), diff --git a/src/downloader.rs b/src/downloader.rs index ea2cc45..2c6595b 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -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, } 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 { diff --git a/src/main.rs b/src/main.rs index 8079031..5e449a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ mod library; mod maintenance; mod platform; mod plex; +mod pot_provider; mod stats; mod theme; mod tray; diff --git a/src/pot_provider.rs b/src/pot_provider.rs new file mode 100644 index 0000000..e8da3de --- /dev/null +++ b/src/pot_provider.rs @@ -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 +//! (avoids the Node.js dependency of the original) plus the upstream +//! Python plugin from . +//! +//! # 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/` 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 { + 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()); + } +} diff --git a/src/web.rs b/src/web.rs index 55d19a9..b99a612 100644 --- a/src/web.rs +++ b/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>) -> 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>) -> 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>) -> 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>) -> 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)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 6fa8df5..7451368 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -1301,6 +1301,16 @@ async function openSettings(){ ${cur.bundled_ytdlp_installed?'✓ installed':'not installed'} +
+ + +
+
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.
+
+ + ${cur.pot_provider_installed?'✓ installed':'not installed'} + +

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