From 2f711604f0929b6a9dfb66af0639b98682dc59e9 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 1 Jun 2026 09:27:57 -0700 Subject: [PATCH] Anti-bot: configurable YouTube player clients (global + per-channel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YouTube's bot-detection cracks down on different player clients over time — the `web` client gets captcha-walled, then later `tv`/`mweb` are the safe ones, etc. Let the user route around whichever client is currently being targeted instead of being stuck with yt-dlp's pick. - New backup.youtube_player_clients config (comma-separated, blank = yt-dlp default). Maps to --extractor-args youtube:player_client=… - Per-channel DownloadOptions.youtube_player_clients override (None = defer to global). Resolved in the downloader's apply_player_client, which omits the flag entirely when empty so yt-dlp keeps its default client set (the recommended baseline). - Threaded into the live downloader at construction + settings save (app + web). UI: a "YouTube player clients" field in both global Settings (desktop + web) and the per-channel options dialog, with hints pointing at "tv,mweb" as the current least-bot-checked combo for captcha-prone channels. Verified the settings round-trip (global persists to config + echoes; per-channel override saves + reads back). 87 tests pass. Co-Authored-By: Claude Opus 4.7 --- src/app.rs | 32 ++++++++++++++++++++++++++++++++ src/config.rs | 9 +++++++++ src/download_options.rs | 7 +++++++ src/downloader.rs | 25 +++++++++++++++++++++++++ src/web.rs | 10 ++++++++++ src/web_ui/index.html | 13 ++++++++++++- 6 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 311209d..d3bf014 100644 --- a/src/app.rs +++ b/src/app.rs @@ -232,6 +232,7 @@ struct ChannelOptionsForm { subs_auto_idx: usize, subs_embed_idx: usize, subtitle_format: String, // "" = global default + youtube_player_clients: String, // "" = global default extra_args: String, // one per line } @@ -314,6 +315,7 @@ impl App { use_bundled_ytdlp, use_pot_provider, ); downloader.subtitle_defaults = config.subtitles.clone(); + downloader.youtube_player_clients = config.backup.youtube_player_clients.clone(); let config_bind = config.web.bind.clone(); let password_set = db.get_setting("password_hash").ok().flatten().is_some(); let plex_path_str = config.plex.library_path @@ -474,6 +476,7 @@ impl App { subs_auto_idx: tri_to_idx(opts.subtitles_auto), subs_embed_idx: tri_to_idx(opts.subtitles_embed), subtitle_format: opts.subtitle_format.clone().unwrap_or_default(), + youtube_player_clients: opts.youtube_player_clients.clone().unwrap_or_default(), extra_args: opts.extra_args.join("\n"), } } @@ -510,6 +513,7 @@ impl App { subtitles_auto: idx_to_tri(f.subs_auto_idx), subtitles_embed: idx_to_tri(f.subs_embed_idx), subtitle_format: trim_opt(&f.subtitle_format), + youtube_player_clients: trim_opt(&f.youtube_player_clients), extra_args: f.extra_args.lines() .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), } @@ -2006,6 +2010,20 @@ impl App { ).on_hover_text("Blank = use the global setting. e.g. srt for Plex."); }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("YouTube player clients"); + ui.add( + egui::TextEdit::singleline(&mut form.youtube_player_clients) + .desired_width(160.0) + .hint_text("global (e.g. tv,mweb)"), + ).on_hover_text( + "Per-channel --extractor-args youtube:player_client override. \ + Blank = use the global setting. If this channel keeps hitting \ + captchas, try 'tv,mweb' — those clients are currently the least \ + bot-checked."); + }); + ui.add_space(6.0); ui.label("Extra yt-dlp args (one per line):"); ui.add( @@ -2457,6 +2475,19 @@ impl App { }); ui.end_row(); + ui.label("YouTube player clients:"); + ui.add( + egui::TextEdit::singleline(&mut self.config.backup.youtube_player_clients) + .desired_width(180.0) + .hint_text("default (e.g. tv,mweb)"), + ).on_hover_text( + "Comma-separated --extractor-args youtube:player_client list. \ + Blank = let yt-dlp pick (recommended). YouTube's bot-detection \ + targets different clients over time; if you keep hitting \ + captchas, 'tv,mweb' are currently the least-checked. \ + Per-channel overrides live in each channel's options."); + ui.end_row(); + ui.label("Web UI port:"); ui.add( egui::DragValue::new(&mut self.config.web.port) @@ -2801,6 +2832,7 @@ impl App { self.downloader.use_bundled_ytdlp = self.config.backup.use_bundled_ytdlp; self.downloader.use_pot_provider = self.config.backup.use_pot_provider; self.downloader.subtitle_defaults = self.config.subtitles.clone(); + self.downloader.youtube_player_clients = self.config.backup.youtube_player_clients.clone(); 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 5d222c0..d65cdbe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,6 +87,14 @@ pub struct BackupSection { /// install the plugin and run the server themselves. #[serde(default)] pub use_pot_provider: bool, + /// YouTube player clients to try, as a comma-separated list passed to + /// `--extractor-args youtube:player_client=…`. Empty = let yt-dlp pick + /// its defaults (recommended). YouTube's bot-detection cracks down on + /// different clients over time; setting e.g. `tv,mweb` routes around a + /// client that's currently being captcha-walled. Per-channel overrides + /// live in [`crate::download_options::DownloadOptions`]. + #[serde(default)] + pub youtube_player_clients: String, } fn default_max_concurrent() -> usize { 3 } @@ -214,6 +222,7 @@ impl Config { max_concurrent: default_max_concurrent(), use_bundled_ytdlp: false, use_pot_provider: false, + youtube_player_clients: String::new(), }, player: PlayerSection::default(), ui: UiSection::default(), diff --git a/src/download_options.rs b/src/download_options.rs index ac78719..bc8de2f 100644 --- a/src/download_options.rs +++ b/src/download_options.rs @@ -85,6 +85,13 @@ pub struct DownloadOptions { #[serde(default)] pub subtitle_format: Option, + /// Per-channel YouTube player-client override (comma-separated, e.g. + /// "tv,mweb"). `None` defers to the global + /// `backup.youtube_player_clients`. Resolved in the downloader, not + /// `apply()`, since the global default lives in config. + #[serde(default)] + pub youtube_player_clients: Option, + /// Raw passthrough — every entry is appended as a separate argument to /// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent /// to Tartube's `extra_cmd_string`. diff --git a/src/downloader.rs b/src/downloader.rs index 217e093..e095910 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -231,6 +231,10 @@ pub struct Downloader { /// [`DownloadOptions`] override individual fields. Set by the app / /// web layer at construction and on settings save. pub subtitle_defaults: crate::config::SubtitlesSection, + /// Global `backup.youtube_player_clients` (comma-separated). Empty = + /// yt-dlp defaults. Per-channel options can override. Set at + /// construction + on settings save. + pub youtube_player_clients: String, /// Scheduled auto-retries: `(fire_at, spec, attempt_number)`. Populated /// when a job fails with a retryable class; drained in [`Self::poll`] /// once `fire_at` passes, re-issuing the download. Kept separate from @@ -269,6 +273,7 @@ impl Downloader { use_pot_provider, pot_server: None, subtitle_defaults: crate::config::SubtitlesSection::default(), + youtube_player_clients: String::new(), retry_queue: Vec::new(), rate_limited_backoff: false, pending_retry_spec: None, @@ -282,6 +287,23 @@ impl Downloader { /// Resolution: a per-channel `Some` wins over the global default for /// each field. When subtitles are disabled (globally or per-channel), /// nothing is emitted — yt-dlp then writes no subs. + /// Append `--extractor-args youtube:player_client=…` when a client + /// list is configured (per-channel override or global default). Empty + /// = omit the flag entirely so yt-dlp uses its own default client set + /// (the recommended baseline). Only meaningful for YouTube; the + /// youtube: namespace is ignored by other extractors so it's harmless + /// to always pass. + fn apply_player_client(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) { + let clients = opts + .and_then(|o| o.youtube_player_clients.as_deref()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(self.youtube_player_clients.as_str()) + .trim(); + if clients.is_empty() { return; } + cmd.arg("--extractor-args") + .arg(format!("youtube:player_client={clients}")); + } + fn apply_subtitle_flags(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) { let g = &self.subtitle_defaults; // Per-channel override-or-global for each knob. @@ -630,6 +652,9 @@ impl Downloader { // overrides. Done here (not in opts.apply) so it works even when a // channel has no options row. self.apply_subtitle_flags(&mut cmd, channel_options); + // YouTube player-client selection (global default + per-channel + // override). Lets the user route around a captcha-walled client. + self.apply_player_client(&mut cmd, channel_options); // Per-channel option overrides win when present — they're applied // last so a `--limit-rate` / `--match-filter` / passthrough arg from // the channel settings takes priority over the global defaults. diff --git a/src/web.rs b/src/web.rs index 138ba8f..7bf750d 100644 --- a/src/web.rs +++ b/src/web.rs @@ -367,6 +367,9 @@ struct SettingsPayload { subtitle_format: String, #[serde(default)] subtitle_langs: String, + /// YouTube player clients (comma-separated). Empty = yt-dlp defaults. + #[serde(default)] + youtube_player_clients: String, } #[derive(Serialize, Deserialize, Clone)] @@ -1220,6 +1223,7 @@ async fn get_settings(State(state): State>) -> impl IntoResponse { let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp; let use_pot_provider = cfg.backup.use_pot_provider; let subs = cfg.subtitles.clone(); + let player_clients_out = cfg.backup.youtube_player_clients.clone(); drop(cfg); let scheduler_next_check_secs = if scheduler_enabled { @@ -1256,6 +1260,7 @@ async fn get_settings(State(state): State>) -> impl IntoResponse { subtitles_embed: subs.embed, subtitle_format: subs.format, subtitle_langs: subs.langs, + youtube_player_clients: player_clients_out.clone(), }) } @@ -1295,6 +1300,7 @@ async fn post_settings( cfg.subtitles.embed = body.subtitles_embed; cfg.subtitles.format = body.subtitle_format.trim().to_string(); cfg.subtitles.langs = body.subtitle_langs.trim().to_string(); + cfg.backup.youtube_player_clients = body.youtube_player_clients.trim().to_string(); if let Err(e) = cfg.save(&state.config_path) { return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response(); @@ -1309,6 +1315,7 @@ async fn post_settings( let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp; let use_pot_provider = cfg.backup.use_pot_provider; let subs = cfg.subtitles.clone(); + let player_clients_out = cfg.backup.youtube_player_clients.clone(); drop(cfg); // Apply the new concurrency limit and binary choices to the live downloader. @@ -1320,6 +1327,7 @@ async fn post_settings( dl.use_bundled_ytdlp = use_bundled_ytdlp; dl.use_pot_provider = use_pot_provider; dl.subtitle_defaults = subs.clone(); + dl.youtube_player_clients = player_clients_out.clone(); } if let Some(new_pwd) = &body.new_download_password { @@ -1374,6 +1382,7 @@ async fn post_settings( subtitles_embed: subs.embed, subtitle_format: subs.format, subtitle_langs: subs.langs, + youtube_player_clients: player_clients_out.clone(), }).into_response() } @@ -2344,6 +2353,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { config.backup.use_pot_provider, ); downloader.subtitle_defaults = config.subtitles.clone(); + downloader.youtube_player_clients = config.backup.youtube_player_clients.clone(); let music_root = downloader.music_root(); let _ = std::fs::create_dir_all(&music_root); let transcode = AtomicBool::new(config.web.transcode); diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 1332088..42d0064 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -957,6 +957,10 @@ async function openChannelOptions(idx){ ${triSelect('opt-subs-embed',opts.subtitles_embed)}
+
+ +
+
Per-channel --extractor-args youtube:player_client override. If this channel keeps hitting captchas, try tv,mweb (least bot-checked).