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