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

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