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

View file

@ -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(),

View file

@ -85,6 +85,13 @@ pub struct DownloadOptions {
#[serde(default)]
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
/// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent
/// to Tartube's `extra_cmd_string`.

View file

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

View file

@ -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<Arc<WebState>>) -> 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<Arc<WebState>>) -> 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);

View file

@ -957,6 +957,10 @@ async function openChannelOptions(idx){
${triSelect('opt-subs-embed',opts.subtitles_embed)}</div>
<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>
<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">
<label>Extra yt-dlp args (one per line)</label>
<textarea id="opt-extra" rows="3" placeholder="--no-mtime
@ -990,6 +994,7 @@ function readChannelOptionsForm(){
subtitles_auto:triValue('opt-subs-auto'),
subtitles_embed:triValue('opt-subs-embed'),
subtitle_format:strOrNull('opt-subs-format'),
youtube_player_clients:strOrNull('opt-player-clients'),
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 id="pot-status" style="font-size:11px;color:var(--muted)"></span>
</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">
<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>
@ -1658,7 +1668,8 @@ async function saveSettings(btn){
const subsEmbed=document.getElementById('cf-subs-embed')?.checked||false;
const subsLangs=document.getElementById('cf-subs-langs')?.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(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl;