Anti-bot: configurable YouTube player clients (global + per-channel)

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 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-01 09:27:57 -07:00
parent fe229bc98d
commit 2f711604f0
6 changed files with 95 additions and 1 deletions

View file

@ -232,6 +232,7 @@ struct ChannelOptionsForm {
subs_auto_idx: usize, subs_auto_idx: usize,
subs_embed_idx: usize, subs_embed_idx: usize,
subtitle_format: String, // "" = global default subtitle_format: String, // "" = global default
youtube_player_clients: String, // "" = global default
extra_args: String, // one per line extra_args: String, // one per line
} }
@ -314,6 +315,7 @@ impl App {
use_bundled_ytdlp, use_pot_provider, use_bundled_ytdlp, use_pot_provider,
); );
downloader.subtitle_defaults = config.subtitles.clone(); downloader.subtitle_defaults = config.subtitles.clone();
downloader.youtube_player_clients = config.backup.youtube_player_clients.clone();
let config_bind = config.web.bind.clone(); let config_bind = config.web.bind.clone();
let password_set = db.get_setting("password_hash").ok().flatten().is_some(); let password_set = db.get_setting("password_hash").ok().flatten().is_some();
let plex_path_str = config.plex.library_path let plex_path_str = config.plex.library_path
@ -474,6 +476,7 @@ impl App {
subs_auto_idx: tri_to_idx(opts.subtitles_auto), subs_auto_idx: tri_to_idx(opts.subtitles_auto),
subs_embed_idx: tri_to_idx(opts.subtitles_embed), subs_embed_idx: tri_to_idx(opts.subtitles_embed),
subtitle_format: opts.subtitle_format.clone().unwrap_or_default(), 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"), extra_args: opts.extra_args.join("\n"),
} }
} }
@ -510,6 +513,7 @@ impl App {
subtitles_auto: idx_to_tri(f.subs_auto_idx), subtitles_auto: idx_to_tri(f.subs_auto_idx),
subtitles_embed: idx_to_tri(f.subs_embed_idx), subtitles_embed: idx_to_tri(f.subs_embed_idx),
subtitle_format: trim_opt(&f.subtitle_format), subtitle_format: trim_opt(&f.subtitle_format),
youtube_player_clients: trim_opt(&f.youtube_player_clients),
extra_args: f.extra_args.lines() extra_args: f.extra_args.lines()
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), .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."); ).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.add_space(6.0);
ui.label("Extra yt-dlp args (one per line):"); ui.label("Extra yt-dlp args (one per line):");
ui.add( ui.add(
@ -2457,6 +2475,19 @@ impl App {
}); });
ui.end_row(); 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.label("Web UI port:");
ui.add( ui.add(
egui::DragValue::new(&mut self.config.web.port) 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_bundled_ytdlp = self.config.backup.use_bundled_ytdlp;
self.downloader.use_pot_provider = self.config.backup.use_pot_provider; self.downloader.use_pot_provider = self.config.backup.use_pot_provider;
self.downloader.subtitle_defaults = self.config.subtitles.clone(); self.downloader.subtitle_defaults = self.config.subtitles.clone();
self.downloader.youtube_player_clients = self.config.backup.youtube_player_clients.clone();
if dir_changed { if dir_changed {
self.channels_root = new_dir.clone(); self.channels_root = new_dir.clone();
self.library_root = new_dir self.library_root = new_dir

View file

@ -87,6 +87,14 @@ pub struct BackupSection {
/// install the plugin and run the server themselves. /// install the plugin and run the server themselves.
#[serde(default)] #[serde(default)]
pub use_pot_provider: bool, 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 } fn default_max_concurrent() -> usize { 3 }
@ -214,6 +222,7 @@ impl Config {
max_concurrent: default_max_concurrent(), max_concurrent: default_max_concurrent(),
use_bundled_ytdlp: false, use_bundled_ytdlp: false,
use_pot_provider: false, use_pot_provider: false,
youtube_player_clients: String::new(),
}, },
player: PlayerSection::default(), player: PlayerSection::default(),
ui: UiSection::default(), ui: UiSection::default(),

View file

@ -85,6 +85,13 @@ pub struct DownloadOptions {
#[serde(default)] #[serde(default)]
pub subtitle_format: Option<String>, pub subtitle_format: Option<String>,
/// 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<String>,
/// Raw passthrough — every entry is appended as a separate argument to /// Raw passthrough — every entry is appended as a separate argument to
/// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent /// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent
/// to Tartube's `extra_cmd_string`. /// to Tartube's `extra_cmd_string`.

View file

@ -231,6 +231,10 @@ pub struct Downloader {
/// [`DownloadOptions`] override individual fields. Set by the app / /// [`DownloadOptions`] override individual fields. Set by the app /
/// web layer at construction and on settings save. /// web layer at construction and on settings save.
pub subtitle_defaults: crate::config::SubtitlesSection, 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 /// Scheduled auto-retries: `(fire_at, spec, attempt_number)`. Populated
/// when a job fails with a retryable class; drained in [`Self::poll`] /// when a job fails with a retryable class; drained in [`Self::poll`]
/// once `fire_at` passes, re-issuing the download. Kept separate from /// once `fire_at` passes, re-issuing the download. Kept separate from
@ -269,6 +273,7 @@ impl Downloader {
use_pot_provider, use_pot_provider,
pot_server: None, pot_server: None,
subtitle_defaults: crate::config::SubtitlesSection::default(), subtitle_defaults: crate::config::SubtitlesSection::default(),
youtube_player_clients: String::new(),
retry_queue: Vec::new(), retry_queue: Vec::new(),
rate_limited_backoff: false, rate_limited_backoff: false,
pending_retry_spec: None, pending_retry_spec: None,
@ -282,6 +287,23 @@ impl Downloader {
/// Resolution: a per-channel `Some` wins over the global default for /// Resolution: a per-channel `Some` wins over the global default for
/// each field. When subtitles are disabled (globally or per-channel), /// each field. When subtitles are disabled (globally or per-channel),
/// nothing is emitted — yt-dlp then writes no subs. /// 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>) { fn apply_subtitle_flags(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) {
let g = &self.subtitle_defaults; let g = &self.subtitle_defaults;
// Per-channel override-or-global for each knob. // 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 // overrides. Done here (not in opts.apply) so it works even when a
// channel has no options row. // channel has no options row.
self.apply_subtitle_flags(&mut cmd, channel_options); 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 // Per-channel option overrides win when present — they're applied
// last so a `--limit-rate` / `--match-filter` / passthrough arg from // last so a `--limit-rate` / `--match-filter` / passthrough arg from
// the channel settings takes priority over the global defaults. // the channel settings takes priority over the global defaults.

View file

@ -367,6 +367,9 @@ struct SettingsPayload {
subtitle_format: String, subtitle_format: String,
#[serde(default)] #[serde(default)]
subtitle_langs: String, subtitle_langs: String,
/// YouTube player clients (comma-separated). Empty = yt-dlp defaults.
#[serde(default)]
youtube_player_clients: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
@ -1220,6 +1223,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp; let use_bundled_ytdlp = cfg.backup.use_bundled_ytdlp;
let use_pot_provider = cfg.backup.use_pot_provider; let use_pot_provider = cfg.backup.use_pot_provider;
let subs = cfg.subtitles.clone(); let subs = cfg.subtitles.clone();
let player_clients_out = cfg.backup.youtube_player_clients.clone();
drop(cfg); drop(cfg);
let scheduler_next_check_secs = if scheduler_enabled { let scheduler_next_check_secs = if scheduler_enabled {
@ -1256,6 +1260,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
subtitles_embed: subs.embed, subtitles_embed: subs.embed,
subtitle_format: subs.format, subtitle_format: subs.format,
subtitle_langs: subs.langs, 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.embed = body.subtitles_embed;
cfg.subtitles.format = body.subtitle_format.trim().to_string(); cfg.subtitles.format = body.subtitle_format.trim().to_string();
cfg.subtitles.langs = body.subtitle_langs.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) { if let Err(e) = cfg.save(&state.config_path) {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response(); 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_bundled_ytdlp = cfg.backup.use_bundled_ytdlp;
let use_pot_provider = cfg.backup.use_pot_provider; let use_pot_provider = cfg.backup.use_pot_provider;
let subs = cfg.subtitles.clone(); let subs = cfg.subtitles.clone();
let player_clients_out = cfg.backup.youtube_player_clients.clone();
drop(cfg); drop(cfg);
// Apply the new concurrency limit and binary choices to the live downloader. // 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_bundled_ytdlp = use_bundled_ytdlp;
dl.use_pot_provider = use_pot_provider; dl.use_pot_provider = use_pot_provider;
dl.subtitle_defaults = subs.clone(); dl.subtitle_defaults = subs.clone();
dl.youtube_player_clients = player_clients_out.clone();
} }
if let Some(new_pwd) = &body.new_download_password { if let Some(new_pwd) = &body.new_download_password {
@ -1374,6 +1382,7 @@ async fn post_settings(
subtitles_embed: subs.embed, subtitles_embed: subs.embed,
subtitle_format: subs.format, subtitle_format: subs.format,
subtitle_langs: subs.langs, subtitle_langs: subs.langs,
youtube_player_clients: player_clients_out.clone(),
}).into_response() }).into_response()
} }
@ -2344,6 +2353,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
config.backup.use_pot_provider, config.backup.use_pot_provider,
); );
downloader.subtitle_defaults = config.subtitles.clone(); downloader.subtitle_defaults = config.subtitles.clone();
downloader.youtube_player_clients = config.backup.youtube_player_clients.clone();
let music_root = downloader.music_root(); let music_root = downloader.music_root();
let _ = std::fs::create_dir_all(&music_root); let _ = std::fs::create_dir_all(&music_root);
let transcode = AtomicBool::new(config.web.transcode); let transcode = AtomicBool::new(config.web.transcode);

View file

@ -957,6 +957,10 @@ async function openChannelOptions(idx){
${triSelect('opt-subs-embed',opts.subtitles_embed)}</div> ${triSelect('opt-subs-embed',opts.subtitles_embed)}</div>
<div class="settings-row"><label>Convert format (blank = global)</label> <div class="settings-row"><label>Convert format (blank = global)</label>
<input type="text" id="opt-subs-format" value="${esc(opts.subtitle_format||'')}" placeholder="srt/vtt/ass" style="width:100px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div> <input type="text" id="opt-subs-format" value="${esc(opts.subtitle_format||'')}" placeholder="srt/vtt/ass" style="width:100px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>YouTube player clients (blank = global)</label>
<input type="text" id="opt-player-clients" value="${esc(opts.youtube_player_clients||'')}" placeholder="e.g. tv,mweb" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-hint" style="margin-bottom:4px">Per-channel <code>--extractor-args youtube:player_client</code> override. If this channel keeps hitting captchas, try <code>tv,mweb</code> (least bot-checked).</div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px"> <div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Extra yt-dlp args (one per line)</label> <label>Extra yt-dlp args (one per line)</label>
<textarea id="opt-extra" rows="3" placeholder="--no-mtime <textarea id="opt-extra" rows="3" placeholder="--no-mtime
@ -990,6 +994,7 @@ function readChannelOptionsForm(){
subtitles_auto:triValue('opt-subs-auto'), subtitles_auto:triValue('opt-subs-auto'),
subtitles_embed:triValue('opt-subs-embed'), subtitles_embed:triValue('opt-subs-embed'),
subtitle_format:strOrNull('opt-subs-format'), subtitle_format:strOrNull('opt-subs-format'),
youtube_player_clients:strOrNull('opt-player-clients'),
extra_args:extra, extra_args:extra,
}; };
} }
@ -1459,6 +1464,11 @@ async function openSettings(){
<span style="font-size:11px;color:var(--muted)">${cur.pot_provider_installed?'✓ installed':'not installed'}</span> <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> <span id="pot-status" style="font-size:11px;color:var(--muted)"></span>
</div> </div>
<div class="settings-row" style="margin-top:8px">
<label for="cf-player-clients">YouTube player clients</label>
<input type="text" id="cf-player-clients" value="${esc(cur.youtube_player_clients||'')}" placeholder="default" style="width:140px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
</div>
<div class="settings-hint" style="margin-bottom:6px">Comma-separated <code>--extractor-args youtube:player_client</code>. Blank = let yt-dlp pick (recommended). YouTube's bot-detection targets different clients over time; if you keep hitting captchas, try <code>tv,mweb</code> — currently the least-checked. Per-channel overrides live in each channel's options.</div>
<hr style="border-color:var(--border);margin:12px 0"> <hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Subtitles (global defaults)</div> <div style="font-weight:700;margin-bottom:8px">Subtitles (global defaults)</div>
<div class="settings-hint" style="margin-bottom:6px">Applied to every download. Individual channels can override these in their Channel options dialog.</div> <div class="settings-hint" style="margin-bottom:6px">Applied to every download. Individual channels can override these in their Channel options dialog.</div>
@ -1658,7 +1668,8 @@ async function saveSettings(btn){
const subsEmbed=document.getElementById('cf-subs-embed')?.checked||false; const subsEmbed=document.getElementById('cf-subs-embed')?.checked||false;
const subsLangs=document.getElementById('cf-subs-langs')?.value||''; const subsLangs=document.getElementById('cf-subs-langs')?.value||'';
const subsFormat=document.getElementById('cf-subs-format')?.value||''; const subsFormat=document.getElementById('cf-subs-format')?.value||'';
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat}; const playerClients=document.getElementById('cf-player-clients')?.value||'';
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat,youtube_player_clients:playerClients};
if(bindMode)payload.bind_mode=bindMode; if(bindMode)payload.bind_mode=bindMode;
if(plexPath!==undefined)payload.plex_library_path=plexPath; if(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl; if(sourceUrl!==undefined)payload.source_url=sourceUrl;