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:
parent
fe229bc98d
commit
2f711604f0
6 changed files with 95 additions and 1 deletions
32
src/app.rs
32
src/app.rs
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
10
src/web.rs
10
src/web.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue