fix(media): harden browser cookie handling

- recognize yt-dlp wrapped cookie-load failures and isolate source fallbacks
- repair extension acknowledgements and Azure media manifest handoff
- improve responsive cookie settings guidance across locales
- pin the tested Companion fix
This commit is contained in:
NimBold
2026-09-23 23:48:36 +03:30
parent 15eb8a4423
commit 7310e28543
11 changed files with 606 additions and 161 deletions
+22 -10
View File
@@ -538,10 +538,7 @@ async fn download_handler(
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
if tokio::time::timeout(EXTENSION_ACK_TIMEOUT, ack_receiver)
.await
.is_err()
{
if !wait_for_extension_acknowledgement(ack_receiver, EXTENSION_ACK_TIMEOUT).await {
remove_extension_ack(&state.extension_acks, &request_id);
// The event may already have reached the frontend even when its
// acknowledgement was delayed or lost. Do not return 503 here:
@@ -704,10 +701,7 @@ async fn deliver_media_discovery_update(
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
if tokio::time::timeout(EXTENSION_ACK_TIMEOUT, ack_receiver)
.await
.is_err()
{
if !wait_for_extension_acknowledgement(ack_receiver, EXTENSION_ACK_TIMEOUT).await {
remove_extension_ack(&state.extension_acks, &request_id);
finish_media_discovery_attempt(
&state.media_handoffs,
@@ -817,6 +811,13 @@ fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
}
}
async fn wait_for_extension_acknowledgement(
receiver: oneshot::Receiver<()>,
timeout: Duration,
) -> bool {
matches!(tokio::time::timeout(timeout, receiver).await, Ok(Ok(())))
}
fn is_valid_media_handoff_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_MEDIA_HANDOFF_ID_BYTES
@@ -1591,7 +1592,7 @@ mod tests {
normalize_headers, normalize_media_discovery_update, normalize_media_handoff_metadata,
normalize_media_url, normalize_referer, normalize_url,
expire_media_handoff, finish_media_discovery_attempt, register_media_handoff,
reject_extension_download,
reject_extension_download, wait_for_extension_acknowledgement,
require_frontend_ready, required_client_nonce, same_origin_url, sanitize_filename,
server_session_for_request, session_binding_requested,
sign_server_proof,
@@ -1616,6 +1617,7 @@ mod tests {
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
fn normalized_discovery_update(
handoff_id: &str,
@@ -2135,7 +2137,17 @@ mod tests {
assert!(reject_extension_download(&registry, "request-2"));
assert!(!reject_extension_download(&registry, "request-2"));
assert!(receiver.await.is_err());
assert!(!wait_for_extension_acknowledgement(receiver, Duration::from_secs(1)).await);
}
#[tokio::test]
async fn only_a_sent_acknowledgement_counts_as_success() {
let (sender, receiver) = tokio::sync::oneshot::channel();
sender.send(()).unwrap();
assert!(wait_for_extension_acknowledgement(receiver, Duration::from_secs(1)).await);
let (_sender, receiver) = tokio::sync::oneshot::channel::<()>();
assert!(!wait_for_extension_acknowledgement(receiver, Duration::ZERO).await);
}
#[test]
+502 -118
View File
@@ -1775,28 +1775,74 @@ fn is_browser_cookie_extraction_error(message: &str) -> bool {
|| lower.contains("could not access browser cookie database")
|| lower.contains("failed to read browser cookie")
|| lower.contains("failed to decrypt with dpapi")
// yt-dlp's CookieLoadError deliberately hides the underlying browser
// database exception behind this message. This classifier is only
// consulted after a browser-cookie attempt has failed.
|| lower.contains("failed to load cookies")
|| missing_cookie_database
}
fn should_retry_without_browser_cookies(
cookie_file: Option<&str>,
fn is_netscape_cookie_file_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("does not look like a netscape format cookies file")
|| lower.contains("http.cookiejar.loaderror")
|| lower.contains("unable to load cookies file")
|| lower.contains("failed to load cookies file")
|| lower.contains("could not load cookies file")
|| lower.contains("failed to read cookies file")
// yt-dlp also wraps Netscape cookie-jar parse/read errors in the same
// CookieLoadError used for browser extraction. The caller tracks that
// the file attempt was active before using this classifier.
|| lower.contains("failed to load cookies")
}
fn media_cookie_browser_is_active(cookie_browser: Option<&str>) -> bool {
cookie_browser.is_some_and(|source| {
let source = source.trim();
!source.is_empty() && !source.eq_ignore_ascii_case("none")
})
}
fn should_fallback_from_browser_cookies(
cookie_browser: Option<&str>,
failure_reason: &str,
fallback_used: bool,
) -> bool {
// Browser cookies are an optional authentication enhancement. Chromium on
// Windows can deny access to its database independently of whether the
// requested media needs authentication, so only this narrowly classified
// extraction failure may downgrade to a public-media attempt.
cookie_file.is_none()
&& !fallback_used
&& cookie_browser.is_some_and(|source| {
let source = source.trim();
!source.is_empty() && !source.eq_ignore_ascii_case("none")
})
// Browser cookies are optional. A browser database can be missing, locked,
// or undecryptable independently of whether the requested media needs
// authentication, so only a classified extraction failure may select a
// Netscape-file or cookie-free fallback.
!fallback_used
&& media_cookie_browser_is_active(cookie_browser)
&& is_browser_cookie_extraction_error(failure_reason)
}
fn should_fallback_from_netscape_cookie_file(
file_fallback_used: bool,
failure_reason: &str,
) -> bool {
file_fallback_used && is_netscape_cookie_file_error(failure_reason)
}
enum BrowserCookieFallback {
NetscapeFile(String),
CookieFree,
}
fn browser_cookie_fallback(
cookie_file: Option<&str>,
failure_reason: &str,
) -> Option<BrowserCookieFallback> {
if !is_browser_cookie_extraction_error(failure_reason) {
return None;
}
match normalize_media_cookie_file(cookie_file).ok().flatten() {
Some(cookie_file) => Some(BrowserCookieFallback::NetscapeFile(cookie_file)),
None => Some(BrowserCookieFallback::CookieFree),
}
}
fn should_cleanup_media_artifacts_after_failure(
failure_reason: &str,
strike: usize,
@@ -2752,6 +2798,18 @@ fn media_metadata_cache_key(
hasher.finish()
}
fn media_cookie_cache_identity(
cookie_file: &Option<String>,
cookie_browser: &Option<String>,
) -> (Option<String>, Option<String>) {
if media_cookie_browser_is_active(cookie_browser.as_deref()) {
// A fallback file does not affect a successful browser extraction.
(None, cookie_browser.clone())
} else {
(cookie_file.clone(), None)
}
}
async fn release_media_metadata_lock(
cache_key: u64,
request_lock: &std::sync::Arc<tokio::sync::Mutex<()>>,
@@ -2814,18 +2872,23 @@ async fn fetch_media_metadata(
) -> Result<MediaMetadata, String> {
properties_window::ensure_main_window(&caller)?;
let url = validate_http_url_route(&url)?.to_string();
let cookie_file = normalize_media_cookie_file(cookie_file.as_deref())?;
let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?;
let cookie_file = normalize_media_cookie_file_for_request(
cookie_file.as_deref(),
cookie_browser.as_deref(),
)?;
let user_agent = user_agent.map(|ua| ua.trim().to_string()).filter(|ua| !ua.is_empty());
let username = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
let password = password.filter(|p| !p.is_empty());
let headers = headers.map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
let cookies = cookies.map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
let proxy = proxy.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
let (cache_cookie_file, cache_cookie_browser) =
media_cookie_cache_identity(&cookie_file, &cookie_browser);
let cache_key = media_metadata_cache_key(
&url,
&cookie_file,
&cookie_browser,
&cache_cookie_file,
&cache_cookie_browser,
&user_agent,
&username,
&password,
@@ -2863,10 +2926,8 @@ async fn fetch_media_metadata(
}
drop(cache_guard);
// A fallback result was resolved without the configured browser session;
// keep that effective identity separate so a later successful cookie
// extraction can refresh authenticated formats instead of hitting stale
// public-only metadata.
// Cache the result under the source that actually produced it. A later
// successful browser read must not reuse public or Netscape-file metadata.
let mut result_cache_key = cache_key;
let result = fetch_media_metadata_uncached(
app_handle.clone(),
@@ -2882,40 +2943,113 @@ async fn fetch_media_metadata(
)
.await;
let result = match (result, cookie_file.as_deref(), cookie_browser.as_deref()) {
(Err(error), None, Some(browser))
if should_retry_without_browser_cookies(None, Some(browser), &error, false) =>
let result = match result {
Err(error)
if should_fallback_from_browser_cookies(
cookie_browser.as_deref(),
&error,
false,
) =>
{
log::warn!(
"yt-dlp could not read browser cookies from {}; retrying media metadata without browser cookies",
browser
"yt-dlp could not read browser cookies from {}; trying the configured cookie fallback",
cookie_browser.as_deref().unwrap_or("selected browser")
);
result_cache_key = media_metadata_cache_key(
&url,
&None,
&None,
&user_agent,
&username,
&password,
&headers,
&cookies,
&proxy,
);
fetch_media_metadata_uncached(
app_handle,
url,
None,
None,
user_agent,
username,
password,
headers,
cookies,
proxy,
)
.await
let cookie_file_fallback =
match browser_cookie_fallback(cookie_file.as_deref(), &error) {
Some(BrowserCookieFallback::NetscapeFile(cookie_file)) => Some(cookie_file),
Some(BrowserCookieFallback::CookieFree) | None => None,
};
if let Some(cookie_file_fallback) = cookie_file_fallback {
let file_cache_key = media_metadata_cache_key(
&url,
&Some(cookie_file_fallback.clone()),
&None,
&user_agent,
&username,
&password,
&headers,
&cookies,
&proxy,
);
match fetch_media_metadata_uncached(
app_handle.clone(),
url.clone(),
Some(cookie_file_fallback.clone()),
None,
user_agent.clone(),
username.clone(),
password.clone(),
headers.clone(),
cookies.clone(),
proxy.clone(),
)
.await
{
Ok(metadata) => {
result_cache_key = file_cache_key;
Ok(metadata)
}
Err(file_error)
if should_fallback_from_netscape_cookie_file(true, &file_error) =>
{
log::warn!("yt-dlp could not use the configured Netscape cookie file; retrying media metadata without cookies");
result_cache_key = media_metadata_cache_key(
&url,
&None,
&None,
&user_agent,
&username,
&password,
&headers,
&cookies,
&proxy,
);
fetch_media_metadata_uncached(
app_handle,
url,
None,
None,
user_agent,
username,
password,
headers,
cookies,
proxy,
)
.await
}
Err(file_error) => Err(file_error),
}
} else {
log::warn!("yt-dlp could not read browser cookies; retrying media metadata without cookies");
result_cache_key = media_metadata_cache_key(
&url,
&None,
&None,
&user_agent,
&username,
&password,
&headers,
&cookies,
&proxy,
);
fetch_media_metadata_uncached(
app_handle,
url,
None,
None,
user_agent,
username,
password,
headers,
cookies,
proxy,
)
.await
}
}
(result, _, _) => result,
other => other,
};
let result = match result {
@@ -2964,8 +3098,11 @@ async fn fetch_media_playlist_metadata(
) -> Result<MediaPlaylistMetadata, String> {
properties_window::ensure_main_window(&caller)?;
let url = validate_http_url_route(&url)?.to_string();
let cookie_file = normalize_media_cookie_file(cookie_file.as_deref())?;
let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?;
let cookie_file = normalize_media_cookie_file_for_request(
cookie_file.as_deref(),
cookie_browser.as_deref(),
)?;
let user_agent = user_agent.map(|ua| ua.trim().to_string()).filter(|ua| !ua.is_empty());
let username = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
let password = password.filter(|p| !p.is_empty());
@@ -2987,29 +3124,76 @@ async fn fetch_media_playlist_metadata(
)
.await;
match (result, cookie_file.as_deref(), cookie_browser.as_deref()) {
(Err(error), None, Some(browser))
if should_retry_without_browser_cookies(None, Some(browser), &error, false) =>
match result {
Err(error)
if should_fallback_from_browser_cookies(
cookie_browser.as_deref(),
&error,
false,
) =>
{
log::warn!(
"yt-dlp could not read browser cookies from {}; retrying playlist metadata without browser cookies",
browser
"yt-dlp could not read browser cookies from {}; trying the configured playlist-cookie fallback",
cookie_browser.as_deref().unwrap_or("selected browser")
);
fetch_media_playlist_metadata_uncached(
app_handle,
url,
None,
None,
user_agent,
username,
password,
headers,
cookies,
proxy,
)
.await
let cookie_file_fallback =
match browser_cookie_fallback(cookie_file.as_deref(), &error) {
Some(BrowserCookieFallback::NetscapeFile(cookie_file)) => Some(cookie_file),
Some(BrowserCookieFallback::CookieFree) | None => None,
};
if let Some(cookie_file_fallback) = cookie_file_fallback {
match fetch_media_playlist_metadata_uncached(
app_handle.clone(),
url.clone(),
Some(cookie_file_fallback),
None,
user_agent.clone(),
username.clone(),
password.clone(),
headers.clone(),
cookies.clone(),
proxy.clone(),
)
.await
{
Err(file_error)
if should_fallback_from_netscape_cookie_file(true, &file_error) =>
{
log::warn!("yt-dlp could not use the configured Netscape cookie file; retrying playlist metadata without cookies");
fetch_media_playlist_metadata_uncached(
app_handle,
url,
None,
None,
user_agent,
username,
password,
headers,
cookies,
proxy,
)
.await
}
result => result,
}
} else {
log::warn!("yt-dlp could not read browser cookies; retrying playlist metadata without cookies");
fetch_media_playlist_metadata_uncached(
app_handle,
url,
None,
None,
user_agent,
username,
password,
headers,
cookies,
proxy,
)
.await
}
}
(result, _, _) => result,
other => other,
}
}
@@ -5053,6 +5237,27 @@ fn normalize_media_cookie_file(source: Option<&str>) -> Result<Option<String>, S
Ok(Some(source.to_string()))
}
fn media_cookie_file_candidate(source: Option<&str>) -> Option<String> {
source
.map(str::trim)
.filter(|source| !source.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_media_cookie_file_for_request(
source: Option<&str>,
cookie_browser: Option<&str>,
) -> Result<Option<String>, String> {
let candidate = media_cookie_file_candidate(source);
if media_cookie_browser_is_active(cookie_browser) {
// A configured file is only a fallback when browser-cookie extraction
// cannot run. Do not let a stale fallback path block the browser path.
Ok(candidate)
} else {
normalize_media_cookie_file(candidate.as_deref())
}
}
fn ytdlp_cookie_browser_arg(browser: &str) -> String {
let trimmed = browser.trim();
if trimmed.eq_ignore_ascii_case("safari") {
@@ -5063,22 +5268,24 @@ fn ytdlp_cookie_browser_arg(browser: &str) -> String {
}
fn ytdlp_cookie_args(cookie_file: Option<&str>, cookie_browser: Option<&str>) -> Vec<String> {
if let Some(cookie_file) = cookie_file.filter(|value| !value.is_empty()) {
return vec![
"--cookies".to_string(),
cookie_file.to_string(),
"--no-write-cookies".to_string(),
];
if media_cookie_browser_is_active(cookie_browser) {
let browser = cookie_browser.unwrap_or_default().trim();
vec![
"--cookies-from-browser".to_string(),
ytdlp_cookie_browser_arg(browser),
]
} else {
cookie_file
.filter(|value| !value.is_empty())
.map(|cookie_file| {
vec![
"--cookies".to_string(),
cookie_file.to_string(),
"--no-write-cookies".to_string(),
]
})
.unwrap_or_default()
}
cookie_browser
.filter(|value| !value.is_empty() && *value != "none")
.map(|browser| {
vec![
"--cookies-from-browser".to_string(),
ytdlp_cookie_browser_arg(browser),
]
})
.unwrap_or_default()
}
fn media_format_and_container_args(format: &str, safe_filename: &str) -> Vec<String> {
@@ -5138,7 +5345,10 @@ pub(crate) async fn start_media_download_internal(
) -> Result<std::path::PathBuf, String> {
let url = validate_http_url_route(&url)?.to_string();
let cookie_source = normalize_media_cookie_source(cookie_source.as_deref())?;
let cookie_file = normalize_media_cookie_file(cookie_file.as_deref())?;
let cookie_file = normalize_media_cookie_file_for_request(
cookie_file.as_deref(),
cookie_source.as_deref(),
)?;
let safe_filename = crate::download_ownership::canonical_download_filename(&filename);
let resolved_dest = resolve_path(&destination, &app_handle);
@@ -5213,8 +5423,19 @@ pub(crate) async fn start_media_download_internal(
.max(0) as usize;
let concurrent_fragments = normalize_media_connections(connections);
let mut strike = 0_usize;
let mut effective_cookie_source = cookie_source;
let browser_cookie_source_active = media_cookie_browser_is_active(cookie_source.as_deref());
let mut effective_cookie_source = if browser_cookie_source_active {
cookie_source.clone()
} else {
None
};
let mut effective_cookie_file = if browser_cookie_source_active {
None
} else {
cookie_file.clone()
};
let mut browser_cookie_fallback_used = false;
let mut file_cookie_fallback_used = false;
let route = crate::network::NetworkRoute::from_proxy(proxy.as_deref());
while strike <= max_retries {
@@ -5273,7 +5494,7 @@ pub(crate) async fn start_media_download_internal(
}
cmd = cmd.args(ytdlp_cookie_args(
cookie_file.as_deref(),
effective_cookie_file.as_deref(),
effective_cookie_source.as_deref(),
));
@@ -5500,11 +5721,7 @@ pub(crate) async fn start_media_download_internal(
let transient = is_transient_network_error(&failure_reason);
let strikes_left = strike < max_retries;
if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) {
let _ = cleanup_media_artifacts(&app_handle, Some(id), &out_path, false).await;
}
if should_retry_without_browser_cookies(
cookie_file.as_deref(),
if should_fallback_from_browser_cookies(
effective_cookie_source.as_deref(),
&failure_reason,
browser_cookie_fallback_used,
@@ -5512,13 +5729,31 @@ pub(crate) async fn start_media_download_internal(
{
let source = effective_cookie_source.clone().unwrap_or_default();
log::warn!(
"yt-dlp could not read browser cookies from {}; retrying media download without browser cookies",
"yt-dlp could not read browser cookies from {}; trying the configured media-cookie fallback",
source
);
effective_cookie_source = None;
browser_cookie_fallback_used = true;
effective_cookie_file =
match browser_cookie_fallback(cookie_file.as_deref(), &failure_reason) {
Some(BrowserCookieFallback::NetscapeFile(cookie_file)) => Some(cookie_file),
Some(BrowserCookieFallback::CookieFree) | None => None,
};
file_cookie_fallback_used = effective_cookie_file.is_some();
continue;
}
if should_fallback_from_netscape_cookie_file(
file_cookie_fallback_used,
&failure_reason,
) {
log::warn!("yt-dlp could not use the configured Netscape cookie file; retrying media download without cookies");
effective_cookie_file = None;
file_cookie_fallback_used = false;
continue;
}
if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) {
let _ = cleanup_media_artifacts(&app_handle, Some(id), &out_path, false).await;
}
if !(transient && strikes_left) {
return Err(failure_reason);
}
@@ -15112,7 +15347,9 @@ mod tests {
build_media_format_options,
collect_download_uris, drain_media_output_lines, filename_from_content_disposition,
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key,
is_browser_cookie_extraction_error, is_netscape_cookie_file_error,
media_cookie_browser_is_active, json_lower,
media_cookie_cache_identity, media_metadata_cache_key,
media_output_template, media_progress_args, media_progress_event_totals,
media_progress_speed,
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
@@ -15139,7 +15376,8 @@ mod tests {
directory_has_non_metadata_entries, directory_has_non_metadata_entries_sync,
primary_path_needs_container_cleanup,
should_cleanup_media_artifacts_after_failure,
should_retry_without_browser_cookies,
browser_cookie_fallback, normalize_media_cookie_file_for_request,
should_fallback_from_browser_cookies, should_fallback_from_netscape_cookie_file,
retry_metadata_with_cookies, should_retry_metadata_with_cookies,
should_send_metadata_credentials, collect_log_files, FirelinkDeepLink,
ytdlp_youtube_extractor_args, YTDLP_YOUTUBE_PLAYER_CLIENTS,
@@ -15153,6 +15391,7 @@ mod tests {
normalize_media_connections,
normalize_media_cookie_source,
media_format_and_container_args,
BrowserCookieFallback,
ytdlp_cookie_browser_arg,
ytdlp_cookie_args,
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
@@ -19184,6 +19423,9 @@ mod tests {
assert!(is_browser_cookie_extraction_error(
"failed to read browser cookie data"
));
assert!(is_browser_cookie_extraction_error(
"ERROR: failed to load cookies"
));
assert!(is_browser_cookie_extraction_error(
"yt-dlp failed while fetching media metadata: ERROR: could not find firefox cookies database in '<HOME>/Library/Application Support/Firefox/Profiles'"
));
@@ -19223,39 +19465,184 @@ mod tests {
}
#[test]
fn retries_once_without_browser_cookies_only_for_cookie_database_failures() {
fn browser_cookie_fallback_uses_a_valid_file_then_preserves_cookie_free_access() {
let cookie_database_error = "yt-dlp failed while fetching media metadata: ERROR: could not find firefox cookies database in '<HOME>/Library/Application Support/Firefox/Profiles'";
let temp_dir = tempfile::tempdir().unwrap();
let cookie_path = temp_dir.path().join("cookies.txt");
std::fs::write(&cookie_path, "# Netscape HTTP Cookie File\n").unwrap();
let cookie_file = std::fs::canonicalize(cookie_path)
.unwrap()
.to_string_lossy()
.into_owned();
assert!(should_retry_without_browser_cookies(
None,
assert!(should_fallback_from_browser_cookies(
Some("firefox"),
cookie_database_error,
false
));
assert!(!should_retry_without_browser_cookies(
None,
assert!(!should_fallback_from_browser_cookies(
Some("firefox"),
cookie_database_error,
true
));
assert!(!should_retry_without_browser_cookies(
None,
assert!(!should_fallback_from_browser_cookies(
Some("none"),
cookie_database_error,
false
));
assert!(!should_retry_without_browser_cookies(
None,
assert!(!should_fallback_from_browser_cookies(
None,
cookie_database_error,
false
));
assert!(!should_retry_without_browser_cookies(
None,
Some("firefox"),
"ERROR: Sign in to confirm you are not a bot",
false
assert!(matches!(
browser_cookie_fallback(Some(&cookie_file), cookie_database_error),
Some(BrowserCookieFallback::NetscapeFile(path)) if path == cookie_file
));
assert!(matches!(
browser_cookie_fallback(None, cookie_database_error),
Some(BrowserCookieFallback::CookieFree)
));
let stale_cookie_file = temp_dir.path().join("stale-cookies.txt");
let stale_cookie_file = stale_cookie_file.to_string_lossy().into_owned();
assert!(matches!(
browser_cookie_fallback(Some(&stale_cookie_file), cookie_database_error),
Some(BrowserCookieFallback::CookieFree)
));
for non_cookie_failure in [
"ERROR: Sign in to confirm you are not a bot",
"ERROR: unable to connect to the proxy server",
"ERROR: requested format is not available",
] {
assert!(!should_fallback_from_browser_cookies(
Some("firefox"),
non_cookie_failure,
false
));
assert!(browser_cookie_fallback(Some(&cookie_file), non_cookie_failure).is_none());
}
}
#[test]
fn netscape_file_fallback_only_retries_cookie_file_parse_failures() {
let malformed_file_error = "ERROR: '/private/tmp/cookies.txt' does not look like a Netscape format cookies file";
assert!(is_netscape_cookie_file_error(malformed_file_error));
assert!(should_fallback_from_netscape_cookie_file(
true,
malformed_file_error
));
assert!(!should_fallback_from_netscape_cookie_file(
false,
malformed_file_error
));
assert!(is_netscape_cookie_file_error("ERROR: failed to load cookies"));
assert!(should_fallback_from_netscape_cookie_file(
true,
"ERROR: failed to load cookies"
));
for unrelated_failure in [
"ERROR: Sign in to confirm you are not a bot",
"ERROR: unable to connect to the proxy server",
"ERROR: requested format is not available",
] {
assert!(!is_netscape_cookie_file_error(unrelated_failure));
assert!(!should_fallback_from_netscape_cookie_file(
true,
unrelated_failure
));
}
}
#[test]
fn stale_cookie_file_does_not_block_a_selected_browser_or_cookie_free_retry() {
let temp_dir = tempfile::tempdir().unwrap();
let stale_cookie_file = temp_dir.path().join("missing-cookie-file.txt");
let stale_cookie_file = stale_cookie_file.to_string_lossy().into_owned();
assert_eq!(
normalize_media_cookie_file_for_request(Some(&stale_cookie_file), Some("chrome"))
.unwrap(),
Some(stale_cookie_file.clone())
);
assert!(normalize_media_cookie_file_for_request(Some(&stale_cookie_file), None).is_err());
assert!(normalize_media_cookie_file_for_request(
Some(&stale_cookie_file),
Some(" NONE ")
)
.is_err());
}
#[test]
fn inactive_cookie_browser_values_do_not_override_file_sources() {
assert!(!media_cookie_browser_is_active(None));
assert!(!media_cookie_browser_is_active(Some("")));
assert!(!media_cookie_browser_is_active(Some(" none ")));
assert!(media_cookie_browser_is_active(Some(" Firefox ")));
let cookie_file = Some("/private/cookies.txt".to_string());
assert_eq!(
media_cookie_cache_identity(&cookie_file, &Some(" NONE ".to_string())),
(cookie_file.clone(), None)
);
}
#[test]
fn metadata_cache_identity_separates_browser_file_and_cookie_free_sources() {
let browser = Some("chrome".to_string());
let first_file = Some("/private/cookies-one.txt".to_string());
let second_file = Some("/private/cookies-two.txt".to_string());
let (first_browser_file, first_browser) =
media_cookie_cache_identity(&first_file, &browser);
let (second_browser_file, second_browser) =
media_cookie_cache_identity(&second_file, &browser);
assert_eq!(first_browser_file, None);
assert_eq!(second_browser_file, None);
assert_eq!(first_browser, browser);
assert_eq!(second_browser, browser);
let (file_only, no_browser) = media_cookie_cache_identity(&first_file, &None);
let (no_file, no_cookie_browser) = media_cookie_cache_identity(&None, &None);
let empty = None;
let browser_key = media_metadata_cache_key(
"https://example.com/media",
&first_browser_file,
&first_browser,
&empty,
&empty,
&empty,
&empty,
&empty,
&empty,
);
let file_key = media_metadata_cache_key(
"https://example.com/media",
&file_only,
&no_browser,
&empty,
&empty,
&empty,
&empty,
&empty,
&empty,
);
let cookie_free_key = media_metadata_cache_key(
"https://example.com/media",
&no_file,
&no_cookie_browser,
&empty,
&empty,
&empty,
&empty,
&empty,
&empty,
);
assert_ne!(browser_key, file_key);
assert_ne!(browser_key, cookie_free_key);
assert_ne!(file_key, cookie_free_key);
}
#[test]
@@ -19279,19 +19666,16 @@ mod tests {
}
#[test]
fn ytdlp_cookie_file_args_are_read_only() {
fn ytdlp_cookie_source_prefers_browser_then_uses_read_only_file_fallback() {
assert_eq!(
ytdlp_cookie_args(Some("/tmp/firelink-cookies.txt"), Some("firefox")),
vec![
"--cookies",
"/tmp/firelink-cookies.txt",
"--no-write-cookies"
]
);
assert_eq!(
ytdlp_cookie_args(None, Some("firefox")),
vec!["--cookies-from-browser", "firefox"]
);
assert_eq!(
ytdlp_cookie_args(Some("/tmp/firelink-cookies.txt"), None),
vec!["--cookies", "/tmp/firelink-cookies.txt", "--no-write-cookies"]
);
assert!(ytdlp_cookie_args(None, None).is_empty());
}
#[test]
+22 -19
View File
@@ -2301,9 +2301,10 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
</div>
))}
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
<label className="text-text-secondary font-semibold pt-1.5">{t($ => $.settings.engine.browserCookiesSource)}</label>
<div className="grid grid-cols-1 items-start gap-2 text-[13px] border-t border-border-modal/50 pt-3 mt-2 sm:grid-cols-[180px_minmax(0,1fr)] sm:items-center sm:gap-4">
<label htmlFor="media-browser-cookie-source" className="text-text-secondary font-semibold">{t($ => $.settings.engine.browserCookiesSource)}</label>
<select
id="media-browser-cookie-source"
value={browserCookieSourceOptions.some(option => option.value === settings.mediaCookieSource) ? settings.mediaCookieSource : 'none'}
onChange={(e) => settings.setMediaCookieSource(e.target.value)}
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent w-full"
@@ -2314,31 +2315,33 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
</select>
</div>
<p className="text-text-muted text-xs mt-1">{t($ => $.settings.engine.cookieDescription)}</p>
<div className="grid grid-cols-[180px_1fr] items-start gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
<div>
<div className="border-t border-border-modal/50 pt-3 mt-2 space-y-2 text-[13px]">
<div className="min-w-0">
<label className="text-text-secondary font-semibold">{t($ => $.settings.engine.cookieFile)}</label>
<p className="text-text-muted text-[11px] mt-1">{t($ => $.settings.engine.cookieFileDescription)}</p>
<p className="text-text-muted text-xs mt-1">{t($ => $.settings.engine.cookieFileDescription)}</p>
</div>
<div className="min-w-0 flex items-center gap-2">
<span className="font-mono text-xs text-text-muted truncate flex-1" title={settings.mediaCookieFile || undefined}>
<div className="grid min-w-0 grid-cols-1 items-center gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<span className="min-w-0 font-mono text-xs text-text-muted truncate" title={settings.mediaCookieFile || undefined}>
{settings.mediaCookieFile || t($ => $.settings.engine.none)}
</span>
<button
type="button"
className="app-button px-2.5 py-1.5 text-xs shrink-0"
onClick={() => void handleBrowseMediaCookieFile()}
>
{t($ => $.settings.engine.chooseCookieFile)}
</button>
{settings.mediaCookieFile && (
<div className="flex flex-wrap gap-2 sm:justify-end">
<button
type="button"
className="app-button px-2.5 py-1.5 text-xs shrink-0"
onClick={() => settings.setMediaCookieFile('')}
className="app-button px-2.5 py-1.5 text-xs"
onClick={() => void handleBrowseMediaCookieFile()}
>
{t($ => $.settings.engine.clearCookieFile)}
{t($ => $.settings.engine.chooseCookieFile)}
</button>
)}
{settings.mediaCookieFile && (
<button
type="button"
className="app-button px-2.5 py-1.5 text-xs"
onClick={() => settings.setMediaCookieFile('')}
>
{t($ => $.settings.engine.clearCookieFile)}
</button>
)}
</div>
</div>
</div>
</div>
+2 -2
View File
@@ -1121,9 +1121,9 @@ const common = {
status: 'Status:',
mediaExtractors: 'Media Extractors',
browserCookiesSource: 'Browser Cookies Source:',
cookieDescription: 'yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.',
cookieDescription: 'Public media usually works without cookies. If a site requires you to sign in, choose the browser where you are signed in; yt-dlp reads its cookies directly, so no manual export is needed. Firelink does not save browser cookie contents.',
cookieFile: 'Netscape cookie file:',
cookieFileDescription: 'Optional Netscape-format cookie export. It takes precedence over the browser source; yt-dlp reads it only during media requests and Firelink does not store its contents.',
cookieFileDescription: 'Usually leave this empty. With a browser selected, Firelink tries this Netscape-format file only if it cannot read that browser’s cookies. With no browser selected, the file is used as the cookie source. Firelink does not save its contents.',
chooseCookieFile: 'Choose cookie file',
clearCookieFile: 'Clear cookie file',
none: 'None',
+2 -2
View File
@@ -1121,9 +1121,9 @@ const fa = {
status: 'وضعیت:',
mediaExtractors: 'استخراج‌کننده‌های رسانه',
browserCookiesSource: 'منبع کوکی‌های مرورگر:',
cookieDescription: 'yt-dlp کوکی‌های مرورگر را می‌خواند تا محدودیت‌های دانلود ویدیو را دور بزند یا به رسانه‌های محدود دسترسی پیدا کند. Firelink کوکی‌های مرورگر را ذخیره نمی‌کند.',
cookieDescription: 'رسانه‌های عمومی معمولاً بدون کوکی کار می‌کنند. اگر سایتی ورود به حساب را لازم می‌داند، مرورگری را انتخاب کنید که در آن وارد حساب شده‌اید؛ yt-dlp کوکی‌ها را مستقیماً از همان مرورگر می‌خواند و نیازی به خروجی‌گرفتن دستی نیست. Firelink محتوای کوکی‌های مرورگر را ذخیره نمی‌کند.',
cookieFile: 'فایل کوکی Netscape:',
cookieFileDescription: 'اختیاری؛ خروجی کوکی با قالب Netscape. این فایل بر منبع مرورگر اولویت دارد؛ yt-dlp فقط هنگام درخواست‌های رسانه آن را می‌خواند و Firelink محتوای آن را ذخیره نمی‌کند.',
cookieFileDescription: 'معمولاً این قسمت را خالی بگذارید. اگر مرورگری انتخاب شده باشد، Firelink فقط وقتی نتواند کوکی‌های آن را بخواند از فایل Netscape استفاده می‌کند. اگر مرورگری انتخاب نشده باشد، این فایل منبع کوکی خواهد بود. Firelink محتوای فایل را ذخیره نمی‌کند.',
chooseCookieFile: 'انتخاب فایل کوکی',
clearCookieFile: 'پاک کردن فایل کوکی',
none: 'هیچ‌کدام',
+2 -2
View File
@@ -1121,9 +1121,9 @@ const he = {
status: 'סטטוס:',
mediaExtractors: 'מחלצי מדיה',
browserCookiesSource: 'מקור עוגיות דפדפן (Browser Cookies):',
cookieDescription: 'yt-dlp קורא עוגיות דפדפן כדי לעקוף הגבלות הורדת וידאו או כדי לגשת למדיה מוגבלת. Firelink אינו שומר עוגיות דפדפן.',
cookieDescription: 'תוכן מדיה ציבורי בדרך כלל פועל ללא עוגיות. אם אתר דורש התחברות, בחרו בדפדפן שבו אתם מחוברים; yt-dlp קורא את העוגיות ישירות ממנו, ואין צורך בייצוא ידני. Firelink אינו שומר את תוכן עוגיות הדפדפן.',
cookieFile: 'קובץ עוגיות Netscape:',
cookieFileDescription: 'אופציונלי; ייצוא עוגיות בפורמט Netscape. הוא קודם למקור הדפדפן; yt-dlp קורא אותו רק במהלך בקשות מדיה ו-Firelink אינו שומר את תוכנו.',
cookieFileDescription: 'בדרך כלל השאירו את השדה ריק. כשנבחר דפדפן, Firelink משתמש בקובץ Netscape רק אם אינו מצליח לקרוא את העוגיות מהדפדפן. ללא דפדפן נבחר, הקובץ משמש כמקור העוגיות. Firelink אינו שומר את תוכנו.',
chooseCookieFile: 'בחירת קובץ עוגיות',
clearCookieFile: 'ניקוי קובץ עוגיות',
none: 'ללא',
+2 -2
View File
@@ -1121,9 +1121,9 @@ const ru = {
status: 'Статус:',
mediaExtractors: 'Экстракторы медиа',
browserCookiesSource: 'Источник файлов cookie браузера:',
cookieDescription: 'yt-dlp читает файлы cookie браузера для обхода ограничений на загрузку видео или доступа к закрытым медиа. Firelink не сохраняет файлы cookie браузера.',
cookieDescription: 'Для общедоступных медиа обычно не нужны файлы cookie. Если сайт требует входа, выберите браузер, в котором выполнен вход; yt-dlp читает cookie напрямую из него, поэтому экспортировать их вручную не нужно. Firelink не сохраняет содержимое cookie браузера.',
cookieFile: 'Файл cookie Netscape:',
cookieFileDescription: 'Необязательно; экспорт cookie в формате Netscape. Он имеет приоритет над источником браузера; yt-dlp читает его только во время запросов медиа, а Firelink не сохраняет его содержимое.',
cookieFileDescription: 'Обычно оставляйте это поле пустым. Если выбран браузер, Firelink использует файл Netscape, только когда не удаётся прочитать cookie из браузера. Если браузер не выбран, файл становится источником cookie. Firelink не сохраняет его содержимое.',
chooseCookieFile: 'Выбрать файл cookie',
clearCookieFile: 'Очистить файл cookie',
none: 'Не использовать',
+2 -2
View File
@@ -1121,9 +1121,9 @@ const uk = {
status: 'Статус:',
mediaExtractors: 'Екстрактори медіа',
browserCookiesSource: 'Джерело Cookies з браузера:',
cookieDescription: 'yt-dlp читає файли cookie з браузера, щоб обійти обмеження на завантаження відео або отримати доступ до медіа з обмеженим доступом. Firelink не зберігає файли cookie з браузера.',
cookieDescription: 'Для загальнодоступних медіа зазвичай не потрібні файли cookie. Якщо сайт вимагає входу, виберіть браузер, у якому ви ввійшли; yt-dlp читає файли cookie безпосередньо з нього, тому експортувати їх вручну не потрібно. Firelink не зберігає вміст файлів cookie браузера.',
cookieFile: 'Файл cookie Netscape:',
cookieFileDescription: 'Необов’язково; експорт cookie у форматі Netscape. Він має пріоритет над джерелом браузера; yt-dlp читає його лише під час медіазапитів, а Firelink не зберігає його вміст.',
cookieFileDescription: 'Зазвичай залишайте це поле порожнім. Якщо вибрано браузер, Firelink використовує файл Netscape лише тоді, коли не вдається прочитати файли cookie з браузера. Якщо браузер не вибрано, файл стає джерелом файлів cookie. Firelink не зберігає його вміст.',
chooseCookieFile: 'Вибрати файл cookie',
clearCookieFile: 'Очистити файл cookie',
none: 'Немає',
+2 -2
View File
@@ -1121,9 +1121,9 @@ const zhCN = {
status: '状态:',
mediaExtractors: '媒体提取器',
browserCookiesSource: '浏览器 Cookies 来源:',
cookieDescription: 'yt-dlp 读取浏览器 cookies 以绕过视频下载限制或访问受限媒体。Firelink 不会保存浏览器 cookies。',
cookieDescription: '公开媒体通常无需 cookies。如果网站要求登录,请选择已登录的浏览器;yt-dlp 会直接读取该浏览器的 cookies,因此无需手动导出。Firelink 不会保存浏览器 cookies 的内容。',
cookieFile: 'Netscape Cookie 文件:',
cookieFileDescription: '可选的 Netscape 格式 Cookie 导出文件。它优先于浏览器来源;yt-dlp 仅在媒体请求期间读取它,Firelink 不会保存其内容。',
cookieFileDescription: '通常留空即可。选择了浏览器后,只有在无法读取浏览器 cookies 时 Firelink 才会尝试此 Netscape 格式文件。未选择浏览器时,此文件会作为 cookies 来源。Firelink 不会保存文件内容。',
chooseCookieFile: '选择 Cookie 文件',
clearCookieFile: '清除 Cookie 文件',
none: '无',
+47 -1
View File
@@ -3184,7 +3184,8 @@ describe('useDownloadStore', () => {
const id = 'startup-media-browser-cookies';
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
mediaCookieSource: 'chrome'
mediaCookieSource: 'chrome',
mediaCookieFile: '/tmp/firelink-cookies.txt'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
useDownloadStore.setState({
downloads: [{
@@ -3220,6 +3221,7 @@ describe('useDownloadStore', () => {
id,
is_media: true,
cookie_source: 'chrome',
cookie_file: '/tmp/firelink-cookies.txt',
});
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(false);
} finally {
@@ -3227,6 +3229,50 @@ describe('useDownloadStore', () => {
}
});
it('uses current browser-cookie settings when a paused media download is re-enqueued', async () => {
const id = 'resume-media-browser-cookie-settings';
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
mediaCookieSource: 'firefox',
mediaCookieFile: '/tmp/firelink-cookies.txt'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
useDownloadStore.setState({
downloads: [{
id,
url: 'https://www.youtube.com/watch?v=resume-browser-cookie-source',
fileName: 'video.mp4',
destination: '/tmp',
status: 'paused',
category: 'Movies',
dateAdded: '',
isMedia: true,
credentialsRequired: false,
hasBeenDispatched: true,
queueId: MAIN_QUEUE_ID,
}] as any[],
backendRegisteredIds: new Set(),
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'resume_download') return false;
if (command === 'enqueue_download') {
return { id, filename: 'video.mp4' };
}
if (command === 'get_pending_order') return [];
return undefined;
});
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(true);
expect(ipc.invokeCommand).toHaveBeenCalledWith('enqueue_download', expect.objectContaining({
item: expect.objectContaining({
id,
is_media: true,
cookie_source: 'firefox',
cookie_file: '/tmp/firelink-cookies.txt',
})
}));
});
it('does not strip a configured site login during startup without keychain access', async () => {
const disposePersistence = initializeDownloadPersistence('main');
const id = 'startup-keychain-gated';