diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 29f5a63..ca54d08 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -819,6 +819,8 @@ pub struct PersistedSettings { pub prevents_display_sleep_while_downloading: bool, pub media_cookie_source: MediaCookieSource, #[serde(default)] + pub media_cookie_browser_profiles: HashMap, + #[serde(default)] pub media_cookie_file: String, pub site_logins: Vec, pub auto_check_updates: bool, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ab00aff..73096a1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2917,6 +2917,7 @@ fn media_metadata_cache_key( url: &str, cookie_file: &Option, cookie_browser: &Option, + cookie_browser_profile: &Option, user_agent: &Option, username: &Option, password: &Option, @@ -2939,6 +2940,7 @@ fn media_metadata_cache_key( file_identity.hash(&mut hasher); } cookie_browser.hash(&mut hasher); + cookie_browser_profile.hash(&mut hasher); user_agent.hash(&mut hasher); username.hash(&mut hasher); password.hash(&mut hasher); @@ -2951,12 +2953,13 @@ fn media_metadata_cache_key( fn media_cookie_cache_identity( cookie_file: &Option, cookie_browser: &Option, -) -> (Option, Option) { + cookie_browser_profile: &Option, +) -> (Option, Option, Option) { if media_cookie_browser_is_active(cookie_browser.as_deref()) { // A fallback file does not affect a successful browser extraction. - (None, cookie_browser.clone()) + (None, cookie_browser.clone(), cookie_browser_profile.clone()) } else { - (cookie_file.clone(), None) + (cookie_file.clone(), None, None) } } @@ -3013,6 +3016,7 @@ async fn fetch_media_metadata( url: String, cookie_file: Option, cookie_browser: Option, + cookie_browser_profile: Option, user_agent: Option, username: Option, password: Option, @@ -3023,6 +3027,10 @@ async fn fetch_media_metadata( properties_window::ensure_main_window(&caller)?; let url = validate_http_url_route(&url)?.to_string(); let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?; + let cookie_browser_profile = normalize_media_cookie_browser_profile( + cookie_browser_profile.as_deref(), + cookie_browser.as_deref(), + )?; let cookie_file = normalize_media_cookie_file_for_request( cookie_file.as_deref(), cookie_browser.as_deref(), @@ -3033,12 +3041,13 @@ async fn fetch_media_metadata( 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_cookie_file, cache_cookie_browser, cache_cookie_browser_profile) = + media_cookie_cache_identity(&cookie_file, &cookie_browser, &cookie_browser_profile); let cache_key = media_metadata_cache_key( &url, &cache_cookie_file, &cache_cookie_browser, + &cache_cookie_browser_profile, &user_agent, &username, &password, @@ -3084,6 +3093,7 @@ async fn fetch_media_metadata( url.clone(), cookie_file.clone(), cookie_browser.clone(), + cookie_browser_profile.clone(), user_agent.clone(), username.clone(), password.clone(), @@ -3115,6 +3125,7 @@ async fn fetch_media_metadata( &url, &Some(cookie_file_fallback.clone()), &None, + &None, &user_agent, &username, &password, @@ -3127,6 +3138,7 @@ async fn fetch_media_metadata( url.clone(), Some(cookie_file_fallback.clone()), None, + None, user_agent.clone(), username.clone(), password.clone(), @@ -3148,6 +3160,7 @@ async fn fetch_media_metadata( &url, &None, &None, + &None, &user_agent, &username, &password, @@ -3160,6 +3173,7 @@ async fn fetch_media_metadata( url, None, None, + None, user_agent, username, password, @@ -3177,6 +3191,7 @@ async fn fetch_media_metadata( &url, &None, &None, + &None, &user_agent, &username, &password, @@ -3189,6 +3204,7 @@ async fn fetch_media_metadata( url, None, None, + None, user_agent, username, password, @@ -3239,6 +3255,7 @@ async fn fetch_media_playlist_metadata( url: String, cookie_file: Option, cookie_browser: Option, + cookie_browser_profile: Option, user_agent: Option, username: Option, password: Option, @@ -3249,6 +3266,10 @@ async fn fetch_media_playlist_metadata( properties_window::ensure_main_window(&caller)?; let url = validate_http_url_route(&url)?.to_string(); let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?; + let cookie_browser_profile = normalize_media_cookie_browser_profile( + cookie_browser_profile.as_deref(), + cookie_browser.as_deref(), + )?; let cookie_file = normalize_media_cookie_file_for_request( cookie_file.as_deref(), cookie_browser.as_deref(), @@ -3265,6 +3286,7 @@ async fn fetch_media_playlist_metadata( url.clone(), cookie_file.clone(), cookie_browser.clone(), + cookie_browser_profile.clone(), user_agent.clone(), username.clone(), password.clone(), @@ -3297,6 +3319,7 @@ async fn fetch_media_playlist_metadata( url.clone(), Some(cookie_file_fallback), None, + None, user_agent.clone(), username.clone(), password.clone(), @@ -3315,6 +3338,7 @@ async fn fetch_media_playlist_metadata( url, None, None, + None, user_agent, username, password, @@ -3333,6 +3357,7 @@ async fn fetch_media_playlist_metadata( url, None, None, + None, user_agent, username, password, @@ -3353,6 +3378,7 @@ async fn fetch_media_playlist_metadata_uncached( url: String, cookie_file: Option, cookie_browser: Option, + cookie_browser_profile: Option, user_agent: Option, username: Option, password: Option, @@ -3395,7 +3421,11 @@ async fn fetch_media_playlist_metadata_uncached( cmd = cmd .args(ytdlp_youtube_extractor_args()) - .args(ytdlp_cookie_args(cookie_file.as_deref(), cookie_browser.as_deref())); + .args(ytdlp_cookie_args( + cookie_file.as_deref(), + cookie_browser.as_deref(), + cookie_browser_profile.as_deref(), + )); let route = crate::network::NetworkRoute::from_proxy(proxy.as_deref()); if let Some(proxy) = route.ytdlp_proxy_value() { @@ -3461,6 +3491,7 @@ async fn fetch_media_metadata_uncached( url: String, cookie_file: Option, cookie_browser: Option, + cookie_browser_profile: Option, user_agent: Option, username: Option, password: Option, @@ -3506,6 +3537,7 @@ async fn fetch_media_metadata_uncached( cmd = cmd.args(ytdlp_cookie_args( cookie_file.as_deref(), cookie_browser.as_deref(), + cookie_browser_profile.as_deref(), )); let route = crate::network::NetworkRoute::from_proxy(proxy.as_deref()); @@ -5709,6 +5741,34 @@ fn normalize_media_cookie_source(source: Option<&str>) -> Result, Err("Unsupported media browser-cookie source".to_string()) } +const MEDIA_COOKIE_PROFILE_INVALID_PREFIX: &str = "MEDIA_COOKIE_PROFILE_INVALID:"; + +fn normalize_media_cookie_browser_profile( + profile: Option<&str>, + cookie_browser: Option<&str>, +) -> Result, String> { + let Some(profile) = profile.map(str::trim).filter(|profile| !profile.is_empty()) else { + return Ok(None); + }; + if !media_cookie_browser_is_active(cookie_browser) { + return Ok(None); + } + + let path = Path::new(profile); + let browser_profile_is_valid = path.is_absolute() + && if cookie_browser.is_some_and(|browser| browser.eq_ignore_ascii_case("safari")) { + path.is_file() + } else { + path.is_dir() + }; + if !browser_profile_is_valid { + return Err(format!( + "{MEDIA_COOKIE_PROFILE_INVALID_PREFIX} browser profile path is missing or inaccessible" + )); + } + Ok(Some(profile.to_string())) +} + const MEDIA_COOKIE_FILE_INVALID_PREFIX: &str = "MEDIA_COOKIE_FILE_INVALID:"; fn normalize_media_cookie_file(source: Option<&str>) -> Result, String> { @@ -5758,21 +5818,32 @@ fn normalize_media_cookie_file_for_request( } } -fn ytdlp_cookie_browser_arg(browser: &str) -> String { +fn ytdlp_cookie_browser_arg(browser: &str, profile: Option<&str>) -> String { let trimmed = browser.trim(); - if trimmed.eq_ignore_ascii_case("safari") { - "safari:".to_string() + let browser = if trimmed.eq_ignore_ascii_case("safari") { + "safari".to_string() } else { trimmed.to_ascii_lowercase() + }; + if let Some(profile) = profile.map(str::trim).filter(|profile| !profile.is_empty()) { + format!("{browser}:{profile}") + } else if browser == "safari" { + "safari:".to_string() + } else { + browser } } -fn ytdlp_cookie_args(cookie_file: Option<&str>, cookie_browser: Option<&str>) -> Vec { +fn ytdlp_cookie_args( + cookie_file: Option<&str>, + cookie_browser: Option<&str>, + cookie_browser_profile: Option<&str>, +) -> Vec { 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), + ytdlp_cookie_browser_arg(browser, cookie_browser_profile), ] } else { cookie_file @@ -5832,6 +5903,7 @@ pub(crate) async fn start_media_download_internal( format_selector: Option, connections: Option, cookie_source: Option, + cookie_browser_profile: Option, cookie_file: Option, speed_limit: Option, username: Option, @@ -5845,6 +5917,10 @@ pub(crate) async fn start_media_download_internal( ) -> Result { let url = validate_http_url_route(&url)?.to_string(); let cookie_source = normalize_media_cookie_source(cookie_source.as_deref())?; + let cookie_browser_profile = normalize_media_cookie_browser_profile( + cookie_browser_profile.as_deref(), + cookie_source.as_deref(), + )?; let cookie_file = normalize_media_cookie_file_for_request( cookie_file.as_deref(), cookie_source.as_deref(), @@ -5934,6 +6010,11 @@ pub(crate) async fn start_media_download_internal( } else { cookie_file.clone() }; + let mut effective_cookie_browser_profile = if browser_cookie_source_active { + cookie_browser_profile.clone() + } else { + None + }; let mut browser_cookie_fallback_used = false; let mut file_cookie_fallback_used = false; let route = crate::network::NetworkRoute::from_proxy(proxy.as_deref()); @@ -5996,6 +6077,7 @@ pub(crate) async fn start_media_download_internal( cmd = cmd.args(ytdlp_cookie_args( effective_cookie_file.as_deref(), effective_cookie_source.as_deref(), + effective_cookie_browser_profile.as_deref(), )); if let Some(ua) = user_agent.as_ref() { @@ -6221,6 +6303,7 @@ pub(crate) async fn start_media_download_internal( source ); effective_cookie_source = None; + effective_cookie_browser_profile = None; browser_cookie_fallback_used = true; effective_cookie_file = match browser_cookie_fallback(cookie_file.as_deref(), &failure_reason) { @@ -13490,6 +13573,7 @@ async fn verify_torrent_data( proxy: None, format_selector: None, cookie_source: None, + cookie_browser_profile: None, cookie_file: None, is_media: Some(false), is_torrent: Some(true), @@ -16086,6 +16170,7 @@ mod tests { 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, + normalize_media_cookie_browser_profile, media_output_template, media_progress_args, media_progress_event_totals, media_progress_speed, cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present, @@ -19179,6 +19264,7 @@ mod tests { "https://example.com/watch?v=1", &None, &Some("firefox".to_string()), + &None, &Some("Custom UA A".to_string()), &None, &None, @@ -19190,6 +19276,7 @@ mod tests { "https://example.com/watch?v=1", &None, &Some("firefox".to_string()), + &None, &Some("Custom UA A".to_string()), &None, &None, @@ -19201,6 +19288,7 @@ mod tests { "https://example.com/watch?v=1", &None, &Some("firefox".to_string()), + &None, &Some("Custom UA A".to_string()), &None, &None, @@ -19212,6 +19300,7 @@ mod tests { "https://example.com/watch?v=1", &None, &Some("firefox".to_string()), + &None, &Some("Custom UA B".to_string()), &None, &None, @@ -19223,6 +19312,19 @@ mod tests { "https://example.com/watch?v=1", &None, &None, + &None, + &Some("Custom UA A".to_string()), + &None, + &None, + &Some("User-Agent: Browser A".to_string()), + &Some("session=one".to_string()), + &None, + ); + let changed_profile = media_metadata_cache_key( + "https://example.com/watch?v=1", + &None, + &Some("firefox".to_string()), + &Some("/profiles/firefox-work".to_string()), &Some("Custom UA A".to_string()), &None, &None, @@ -19234,6 +19336,7 @@ mod tests { assert_ne!(base, changed_headers); assert_ne!(base, changed_cookies); assert_ne!(base, changed_user_agent); + assert_ne!(base, changed_profile); assert_ne!(base, without_browser); } @@ -19254,6 +19357,7 @@ mod tests { &None, &None, &None, + &None, ); std::fs::write(&path, b"# Netscape HTTP Cookie File\nchanged\n").unwrap(); @@ -19267,6 +19371,7 @@ mod tests { &None, &None, &None, + &None, ); assert_ne!(initial, replaced); @@ -20448,8 +20553,8 @@ mod tests { 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) + media_cookie_cache_identity(&cookie_file, &Some(" NONE ".to_string()), &None), + (cookie_file.clone(), None, None) ); } @@ -20458,22 +20563,29 @@ mod tests { 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); + let first_profile = Some("/profiles/chrome-work".to_string()); + let second_profile = Some("/profiles/chrome-personal".to_string()); + let (first_browser_file, first_browser, first_browser_profile) = + media_cookie_cache_identity(&first_file, &browser, &first_profile); + let (second_browser_file, second_browser, second_browser_profile) = + media_cookie_cache_identity(&second_file, &browser, &second_profile); assert_eq!(first_browser_file, None); assert_eq!(second_browser_file, None); assert_eq!(first_browser, browser); assert_eq!(second_browser, browser); + assert_eq!(first_browser_profile, first_profile); + assert_eq!(second_browser_profile, second_profile); - 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 (file_only, no_browser, no_profile) = + media_cookie_cache_identity(&first_file, &None, &first_profile); + let (no_file, no_cookie_browser, no_cookie_profile) = + media_cookie_cache_identity(&None, &None, &None); let empty = None; let browser_key = media_metadata_cache_key( "https://example.com/media", &first_browser_file, &first_browser, + &first_browser_profile, &empty, &empty, &empty, @@ -20485,6 +20597,7 @@ mod tests { "https://example.com/media", &file_only, &no_browser, + &no_profile, &empty, &empty, &empty, @@ -20496,6 +20609,7 @@ mod tests { "https://example.com/media", &no_file, &no_cookie_browser, + &no_cookie_profile, &empty, &empty, &empty, @@ -20506,6 +20620,19 @@ mod tests { assert_ne!(browser_key, file_key); assert_ne!(browser_key, cookie_free_key); assert_ne!(file_key, cookie_free_key); + let other_profile_key = media_metadata_cache_key( + "https://example.com/media", + &second_browser_file, + &second_browser, + &second_browser_profile, + &empty, + &empty, + &empty, + &empty, + &empty, + &empty, + ); + assert_ne!(browser_key, other_profile_key); } #[test] @@ -20520,25 +20647,100 @@ mod tests { assert!(normalize_media_cookie_source(Some("unknown-browser")).is_err()); } + #[test] + fn media_cookie_browser_profile_requires_browser_specific_absolute_paths_without_echoing_them() { + let directory = tempfile::tempdir().unwrap(); + let profile = directory.path().to_string_lossy().into_owned(); + assert_eq!( + normalize_media_cookie_browser_profile(Some(&profile), Some("opera")).unwrap(), + Some(profile.clone()) + ); + assert_eq!( + normalize_media_cookie_browser_profile(Some(" "), Some("opera")).unwrap(), + None + ); + assert_eq!( + normalize_media_cookie_browser_profile(Some(&profile), None).unwrap(), + None + ); + assert!(normalize_media_cookie_browser_profile(Some(&profile), Some("safari")).is_err()); + + let safari_cookie_database = directory.path().join("Cookies.binarycookies"); + std::fs::write(&safari_cookie_database, b"cookie database fixture").unwrap(); + let safari_cookie_database = safari_cookie_database.to_string_lossy().into_owned(); + assert_eq!( + normalize_media_cookie_browser_profile( + Some(&safari_cookie_database), + Some("safari") + ) + .unwrap(), + Some(safari_cookie_database) + ); + + let missing = directory.path().join("private-profile-that-does-not-exist"); + let missing = missing.to_string_lossy().into_owned(); + let error = normalize_media_cookie_browser_profile(Some(&missing), Some("opera")) + .expect_err("missing browser profile folders must fail closed"); + assert!(error.starts_with("MEDIA_COOKIE_PROFILE_INVALID:")); + assert!(!error.contains(&missing)); + + let relative = "private-relative-profile"; + let error = normalize_media_cookie_browser_profile(Some(relative), Some("opera")) + .expect_err("relative profile folders must fail closed"); + assert!(error.starts_with("MEDIA_COOKIE_PROFILE_INVALID:")); + assert!(!error.contains(relative)); + + let missing_safari_database = directory.path().join("missing-cookies.binarycookies"); + let missing_safari_database = missing_safari_database.to_string_lossy().into_owned(); + let error = normalize_media_cookie_browser_profile( + Some(&missing_safari_database), + Some("safari"), + ) + .expect_err("missing Safari cookie databases must fail closed"); + assert!(error.starts_with("MEDIA_COOKIE_PROFILE_INVALID:")); + assert!(!error.contains(&missing_safari_database)); + } + #[test] fn ytdlp_cookie_browser_arg_formats_safari_and_other_browsers() { - assert_eq!(ytdlp_cookie_browser_arg("safari"), "safari:"); - assert_eq!(ytdlp_cookie_browser_arg(" Safari "), "safari:"); - assert_eq!(ytdlp_cookie_browser_arg("chrome"), "chrome"); - assert_eq!(ytdlp_cookie_browser_arg(" Firefox "), "firefox"); + assert_eq!(ytdlp_cookie_browser_arg("safari", None), "safari:"); + assert_eq!(ytdlp_cookie_browser_arg(" Safari ", None), "safari:"); + assert_eq!(ytdlp_cookie_browser_arg("chrome", None), "chrome"); + assert_eq!(ytdlp_cookie_browser_arg(" Firefox ", None), "firefox"); + assert_eq!(ytdlp_cookie_browser_arg("opera", None), "opera"); + assert_eq!( + ytdlp_cookie_args(None, Some("safari"), Some("/cookies/Cookies.binarycookies")), + vec![ + "--cookies-from-browser", + "safari:/cookies/Cookies.binarycookies" + ] + ); } #[test] fn ytdlp_cookie_source_prefers_browser_then_uses_read_only_file_fallback() { assert_eq!( - ytdlp_cookie_args(Some("/tmp/firelink-cookies.txt"), Some("firefox")), + ytdlp_cookie_args(Some("/tmp/firelink-cookies.txt"), Some("firefox"), None), vec!["--cookies-from-browser", "firefox"] ); assert_eq!( - ytdlp_cookie_args(Some("/tmp/firelink-cookies.txt"), None), + ytdlp_cookie_args(Some("/tmp/firelink-cookies.txt"), None, None), vec!["--cookies", "/tmp/firelink-cookies.txt", "--no-write-cookies"] ); - assert!(ytdlp_cookie_args(None, None).is_empty()); + assert!(ytdlp_cookie_args(None, None, None).is_empty()); + } + + #[test] + fn ytdlp_cookie_browser_profile_is_a_single_argument_and_preserves_spaces() { + let profile = r"C:\Users\Nima\AppData\Roaming\Opera Software\Opera GX Stable"; + let args = ytdlp_cookie_args(None, Some("opera"), Some(profile)); + assert_eq!( + args, + vec![ + "--cookies-from-browser".to_string(), + format!("opera:{profile}") + ] + ); } #[test] diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 3282d38..0cda487 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1138,6 +1138,7 @@ pub struct SpawnPayload { pub proxy: Option, pub format_selector: Option, pub cookie_source: Option, + pub cookie_browser_profile: Option, pub cookie_file: Option, pub is_media: bool, pub is_torrent: bool, @@ -9317,6 +9318,7 @@ impl SidecarSpawner for ProductionSpawner { payload.format_selector.clone(), payload.connections, payload.cookie_source.clone(), + payload.cookie_browser_profile.clone(), payload.cookie_file.clone(), payload.speed_limit.clone(), payload.username.clone(), @@ -9390,6 +9392,9 @@ pub struct EnqueueItem { pub cookie_source: Option, #[serde(default)] #[ts(optional)] + pub cookie_browser_profile: Option, + #[serde(default)] + #[ts(optional)] pub cookie_file: Option, pub is_media: Option, #[serde(default)] @@ -9479,6 +9484,7 @@ impl EnqueueItem { self.password = None; self.headers = None; self.cookies = None; + self.cookie_browser_profile = None; self.cookie_file = None; } } @@ -9525,6 +9531,7 @@ impl EnqueueItem { proxy: item.proxy, format_selector: item.format_selector, cookie_source: item.cookie_source, + cookie_browser_profile: item.cookie_browser_profile, cookie_file: item.cookie_file, is_media: media, is_torrent: item.is_torrent.unwrap_or(false), @@ -10265,6 +10272,26 @@ mod tests { assert!(task.payload.cookies.is_none()); } + #[test] + fn media_enqueue_preserves_browser_cookie_profile_in_spawn_payload() { + let task = EnqueueItem { + is_media: Some(true), + cookie_source: Some("opera".to_string()), + cookie_browser_profile: Some( + r"C:\Users\Nima\AppData\Roaming\Opera Software\Opera GX Stable".to_string(), + ), + ..Default::default() + } + .into_task(); + + assert!(matches!(task.kind, TaskKind::Media)); + assert_eq!(task.payload.cookie_source.as_deref(), Some("opera")); + assert_eq!( + task.payload.cookie_browser_profile.as_deref(), + Some(r"C:\Users\Nima\AppData\Roaming\Opera Software\Opera GX Stable") + ); + } + #[test] fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() { assert_eq!( diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2c75d58..69f0f07 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -654,6 +654,7 @@ fn sanitize_persisted_setting_values(state: &mut Value) { "vivaldi", "whale", ], ); + sanitize_media_cookie_browser_profiles(state); if let Some(scheduler) = state.get_mut("scheduler").and_then(Value::as_object_mut) { sanitize_allowed_string( @@ -723,6 +724,38 @@ fn sanitize_string_setting(state: &mut serde_json::Map, key: &str } } +fn sanitize_media_cookie_browser_profiles(state: &mut serde_json::Map) { + const BROWSERS: &[&str] = &[ + "safari", "chrome", "chromium", "firefox", "edge", "brave", "opera", "vivaldi", + "whale", + ]; + + if state + .get("mediaCookieBrowserProfiles") + .is_some_and(|value| !value.is_object()) + { + state.remove("mediaCookieBrowserProfiles"); + return; + } + + if let Some(profiles) = state + .get_mut("mediaCookieBrowserProfiles") + .and_then(Value::as_object_mut) + { + profiles.retain(|browser, profile| { + BROWSERS.contains(&browser.as_str()) + && profile + .as_str() + .is_some_and(|path| !path.trim().is_empty()) + }); + for profile in profiles.values_mut() { + if let Some(path) = profile.as_str().map(str::trim) { + *profile = Value::String(path.to_string()); + } + } + } +} + fn sanitize_torrent_network_string( state: &mut serde_json::Map, key: &str, @@ -752,6 +785,16 @@ fn sanitize_allowed_string( } fn validate_settings(settings: &mut PersistedSettings) { + settings.media_cookie_browser_profiles.retain(|browser, profile| { + matches!( + browser.as_str(), + "safari" | "chrome" | "chromium" | "firefox" | "edge" | "brave" | "opera" + | "vivaldi" | "whale" + ) && !profile.trim().is_empty() + }); + for profile in settings.media_cookie_browser_profiles.values_mut() { + *profile = profile.trim().to_string(); + } settings.main_window_size = crate::window_geometry::normalize_main_window_size( settings.main_window_size.as_ref(), ); @@ -1096,6 +1139,7 @@ fn default_settings() -> PersistedSettings { prevents_sleep_while_downloading: true, prevents_display_sleep_while_downloading: false, media_cookie_source: MediaCookieSource::default(), + media_cookie_browser_profiles: HashMap::new(), media_cookie_file: String::new(), site_logins: Vec::new(), auto_check_updates: true, @@ -1155,6 +1199,40 @@ mod tests { ); } + #[test] + fn legacy_settings_hydrate_browser_cookie_profiles_as_empty_and_sanitize_invalid_entries() { + let legacy = json!({ + "state": { + "mediaCookieSource": "opera" + } + }); + let settings = decode_stored_settings(&Value::String(legacy.to_string())).unwrap(); + assert!(settings.media_cookie_browser_profiles.is_empty()); + + let malformed = json!({ + "state": { + "mediaCookieSource": "opera", + "mediaCookieBrowserProfiles": { + "opera": " C:\\Users\\Nima\\Opera GX ", + "chrome": " ", + "none": "ignored", + "unknown-browser": "ignored", + "firefox": 42 + } + } + }); + let settings = decode_stored_settings(&Value::String(malformed.to_string())).unwrap(); + assert_eq!( + settings.media_cookie_browser_profiles, + [( + "opera".to_string(), + "C:\\Users\\Nima\\Opera GX".to_string() + )] + .into_iter() + .collect() + ); + } + #[test] fn decodes_zustand_envelope_and_preserves_non_default_startup_settings() { let stored = json!({ diff --git a/src-tauri/tests/production_contract.rs b/src-tauri/tests/production_contract.rs index b90a0bc..893e8d9 100644 --- a/src-tauri/tests/production_contract.rs +++ b/src-tauri/tests/production_contract.rs @@ -80,6 +80,8 @@ fn headless_enqueue_contract_strips_torrent_credentials_before_task_creation() { password: Some("secret".to_string()), headers: Some("Cookie: session=secret".to_string()), cookies: Some("session=secret".to_string()), + cookie_source: Some("opera".to_string()), + cookie_browser_profile: Some("/private/opera-profile".to_string()), is_torrent: Some(true), ..EnqueueItem::default() }; @@ -88,6 +90,7 @@ fn headless_enqueue_contract_strips_torrent_credentials_before_task_creation() { assert!(task.payload.password.is_none()); assert!(task.payload.headers.is_none()); assert!(task.payload.cookies.is_none()); + assert!(task.payload.cookie_browser_profile.is_none()); } #[test] diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index f88b37d..9311624 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { TorrentWebSeed } from "./TorrentWebSeed"; -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, cookie_file?: string, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, replace_existing_fingerprint?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, cookie_browser_profile?: string, cookie_file?: string, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, replace_existing_fingerprint?: string, }; diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index b4e111e..dcefac4 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -12,4 +12,4 @@ import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; import type { WindowControlStyle } from "./WindowControlStyle"; -export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerTriggeredStartKey?: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, mediaCookieFile: string, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerTriggeredStartKey?: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, mediaCookieBrowserProfiles: { [key in string]: string }, mediaCookieFile: string, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index aca2f53..e897794 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -184,6 +184,7 @@ export const AddDownloadsModal = () => { const mediaMetadataErrorLabel = (reason: AddDownloadDraftRow['metadataBlockedReason']): string => { switch (reason) { case 'cookie-file': return t($ => $.addDownloads.cookieFileInvalid); + case 'cookie-profile': return t($ => $.addDownloads.cookieBrowserProfileInvalid); case 'youtube-bot': return t($ => $.addDownloads.youtubeBotBlocked); case 'youtube-auth': return t($ => $.addDownloads.youtubeAuthenticationRequired); case 'youtube-po-token': return t($ => $.addDownloads.youtubePoTokenRequired); @@ -821,6 +822,9 @@ export const AddDownloadsModal = () => { if (row.isMedia) { const { mediaCookieSource } = settingsStore; const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; + const browserProfile = browserArg + ? settingsStore.mediaCookieBrowserProfiles?.[browserArg]?.trim() || null + : null; let keychainPassword = null; if (login && !useAuth && keychainAccessReady) { try { @@ -841,6 +845,7 @@ export const AddDownloadsModal = () => { url: row.sourceUrl, cookieFile: settingsStore.mediaCookieFile?.trim() || null, cookieBrowser: browserArg, + cookieBrowserProfile: browserProfile, userAgent: settingsStore.customUserAgent.trim() || null, username: useAuth ? username.trim() || null diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 7dfebe9..393e634 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -21,6 +21,7 @@ import { resolveDownloadFraction } from '../utils/downloadProgress'; import { isTorrentWaitingForPeers } from '../utils/torrentPresentation'; +import { isInvalidMediaCookieBrowserProfileError } from '../utils/downloadErrors'; import { COLUMN_ALIGNMENT_JUSTIFY, getDownloadActionPosition, @@ -264,6 +265,9 @@ export const DownloadItem = React.memo(({ ? t($ => $.downloads.errors.nameResolutionFailed) : downloadStatusLabel : downloadStatusLabel; + const visibleLastError = isInvalidMediaCookieBrowserProfileError(download.lastError) + ? t($ => $.addDownloads.cookieBrowserProfileInvalid) + : download.lastError; const downloadedSizeLabel = sizeDisplay.totalIsEstimate ? t($ => $.downloads.size.downloadedOfApproximate, { downloaded: sizeDisplay.downloaded ?? '', @@ -376,7 +380,7 @@ export const DownloadItem = React.memo(({ || download.status === 'retrying' || download.lastErrorKind === 'destinationAccess' ) - ? download.lastError + ? visibleLastError : (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? `${downloadStatusLabel} #${queueIndex + 1}` : waitingForPeers diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index f3e743e..439ae81 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -54,6 +54,7 @@ import { moveSelectedBlockToIndex } from '../utils/queueOrdering'; import { selectContextMenuTarget, updateDownloadSelection } from '../utils/downloadSelection'; +import { isInvalidMediaCookieBrowserProfileError } from '../utils/downloadErrors'; import { createColumnResizeSession } from '../utils/columnResize'; import { clampFloatingPosition } from '../utils/floatingPosition'; import { FloatingQueueSubmenu } from './FloatingQueueSubmenu'; @@ -1988,7 +1989,11 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC download => download.id === item.id ); const reason = latest?.lastError?.trim(); - throw new Error(reason || t($ => $.downloadTable.backendRejectedStart)); + throw new Error(reason + ? isInvalidMediaCookieBrowserProfileError(reason) + ? t($ => $.addDownloads.cookieBrowserProfileInvalid) + : reason + : t($ => $.downloadTable.backendRejectedStart)); } } catch (error) { console.error("Failed to resume:", error); diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index ad15d1e..a9914a1 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -55,6 +55,7 @@ import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREA import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation'; import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle'; import { isTorrentWaitingForPeers } from '../utils/torrentPresentation'; +import { isInvalidMediaCookieBrowserProfileError } from '../utils/downloadErrors'; import { copyTorrentFilePath } from '../utils/torrentFilePath'; import { useWindowFocusState } from '../utils/windowFocus'; import { useWindowMaximizedState } from '../utils/windowMaximized'; @@ -1433,7 +1434,9 @@ export const PropertiesWindowApp = () => { : t($ => $.downloads.errors.nameResolutionFailed)}

)} - {snapshot.lastError} + {isInvalidMediaCookieBrowserProfileError(snapshot.lastError) + ? t($ => $.addDownloads.cookieBrowserProfileInvalid) + : snapshot.lastError} } diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 28087f7..f307e87 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -909,6 +909,23 @@ runEngineChecks(false); } }; + const handleBrowseMediaCookieBrowserProfile = async () => { + const browser = settings.mediaCookieSource; + if (browser === 'none' || !browserCookieSourceOptions.some(option => option.value === browser)) return; + try { + const selected = await open({ + directory: browser !== 'safari', + multiple: false, + defaultPath: settings.mediaCookieBrowserProfiles[browser] || undefined + }); + if (selected && typeof selected === 'string') { + settings.setMediaCookieBrowserProfile(browser, selected); + } + } catch { + console.error('Failed to select media browser profile folder.'); + } + }; + const handleAddLogin = async () => { if (saveLoginInFlight.current) return; const fieldErrors: typeof loginFieldErrors = {}; @@ -2315,6 +2332,51 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled

{t($ => $.settings.engine.cookieDescription)}

+
+
+ +

+ {settings.mediaCookieSource === 'safari' + ? t($ => $.settings.engine.safariCookieDatabaseDescription) + : t($ => $.settings.engine.browserProfileFolderDescription)} +

+
+
+ + {settings.mediaCookieSource === 'none' + ? t($ => $.settings.engine.none) + : settings.mediaCookieBrowserProfiles[settings.mediaCookieSource] || t($ => $.settings.engine.none)} + +
+ + {settings.mediaCookieSource !== 'none' && settings.mediaCookieBrowserProfiles[settings.mediaCookieSource] && ( + + )} +
+
+
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index e41f970..c467cc2 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -675,6 +675,7 @@ const common = { playlistNoEntries: 'Playlist contains no downloadable entries', invalidMediaMetadata: 'Invalid media metadata or no formats found', cookieFileInvalid: 'The selected cookie file is unavailable or is not a regular file. Choose it again in Settings → Media Extractors.', + cookieBrowserProfileInvalid: 'The selected browser profile path is unavailable. Choose the browser’s profile folder or Safari cookies database again in Settings → Media Extractors.', youtubeBotBlocked: 'YouTube blocked this request as automated. Select a signed-in browser source or Netscape cookie file, then refresh metadata.', youtubeAuthenticationRequired: 'YouTube requires authentication for this media. Sign in to the browser account, export Netscape cookies, or select the correct browser source, then refresh metadata.', youtubePoTokenRequired: 'YouTube requires a valid PO token for this client. Try a signed-in browser source or Netscape cookie file, then refresh metadata.', @@ -1139,6 +1140,13 @@ const common = { mediaExtractors: 'Media Extractors', browserCookiesSource: 'Browser Cookies Source:', 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.', + browserProfileFolder: 'Browser profile folder (optional):', + browserProfileFolderDescription: 'Leave this empty to use yt-dlp’s default location. Choose the folder containing the selected browser’s profile data. Firelink remembers one path per browser.', + chooseBrowserProfileFolder: 'Choose profile folder', + safariCookieDatabaseLabel: 'Safari cookies database file (optional):', + safariCookieDatabaseDescription: 'Leave this empty to use yt-dlp’s default location. Choose the Safari cookies database file.', + chooseSafariCookieDatabase: 'Choose Safari cookie database', + clearBrowserProfilePath: 'Clear profile path', cookieFile: 'Netscape cookie file:', 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', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index e6e3d71..582f10e 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -675,6 +675,7 @@ const fa = { playlistNoEntries: 'لیست پخش شامل هیچ ورودی قابل دانلودی نیست', invalidMediaMetadata: 'متادیتای رسانه نامعتبر است یا هیچ فرمتی یافت نشد', cookieFileInvalid: 'فایل کوکی انتخاب‌شده در دسترس نیست یا فایل معمولی نیست. آن را دوباره از تنظیمات → استخراج‌کننده‌های رسانه انتخاب کنید.', + cookieBrowserProfileInvalid: 'مسیر نمایه مرورگر انتخاب‌شده در دسترس نیست. پوشه نمایه مرورگر یا فایل کوکی‌های Safari را دوباره از تنظیمات → استخراج‌کننده‌های رسانه انتخاب کنید.', youtubeBotBlocked: 'یوتیوب این درخواست را خودکار تشخیص داد. یک منبع مرورگر واردشده یا فایل کوکی Netscape انتخاب کنید و فراداده را تازه کنید.', youtubeAuthenticationRequired: 'یوتیوب برای این رسانه به احراز هویت نیاز دارد. وارد حساب مرورگر شوید، کوکی Netscape صادر کنید یا منبع درست مرورگر را انتخاب کنید و فراداده را تازه کنید.', youtubePoTokenRequired: 'یوتیوب برای این کلاینت به PO token معتبر نیاز دارد. یک منبع مرورگر واردشده یا فایل کوکی Netscape را امتحان کنید و فراداده را تازه کنید.', @@ -1139,6 +1140,13 @@ const fa = { mediaExtractors: 'استخراج‌کننده‌های رسانه', browserCookiesSource: 'منبع کوکی‌های مرورگر:', cookieDescription: 'رسانه‌های عمومی معمولاً بدون کوکی کار می‌کنند. اگر سایتی ورود به حساب را لازم می‌داند، مرورگری را انتخاب کنید که در آن وارد حساب شده‌اید؛ yt-dlp کوکی‌ها را مستقیماً از همان مرورگر می‌خواند و نیازی به خروجی‌گرفتن دستی نیست. Firelink محتوای کوکی‌های مرورگر را ذخیره نمی‌کند.', + browserProfileFolder: 'پوشه نمایه مرورگر (اختیاری):', + browserProfileFolderDescription: 'برای استفاده از مسیر پیش‌فرض yt-dlp این قسمت را خالی بگذارید. پوشه داده‌های نمایه مرورگر انتخاب‌شده را انتخاب کنید. Firelink برای هر مرورگر یک مسیر را به خاطر می‌سپارد.', + chooseBrowserProfileFolder: 'انتخاب پوشه نمایه', + safariCookieDatabaseLabel: 'فایل پایگاه داده کوکی‌های Safari (اختیاری):', + safariCookieDatabaseDescription: 'برای استفاده از مسیر پیش‌فرض yt-dlp این قسمت را خالی بگذارید. فایل پایگاه داده کوکی‌های Safari را انتخاب کنید.', + chooseSafariCookieDatabase: 'انتخاب پایگاه داده کوکی Safari', + clearBrowserProfilePath: 'پاک کردن مسیر نمایه', cookieFile: 'فایل کوکی Netscape:', cookieFileDescription: 'معمولاً این قسمت را خالی بگذارید. اگر مرورگری انتخاب شده باشد، Firelink فقط وقتی نتواند کوکی‌های آن را بخواند از فایل Netscape استفاده می‌کند. اگر مرورگری انتخاب نشده باشد، این فایل منبع کوکی خواهد بود. Firelink محتوای فایل را ذخیره نمی‌کند.', chooseCookieFile: 'انتخاب فایل کوکی', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 3080ed3..9ab22c4 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -675,6 +675,7 @@ const he = { playlistNoEntries: 'רשימת ההשמעה אינה מכילה פריטים הניתנים להורדה', invalidMediaMetadata: 'מטא נתונים של המדיה אינם תקינים או שלא נמצאו פורמטים', cookieFileInvalid: 'קובץ העוגיות שנבחר אינו זמין או אינו קובץ רגיל. בחר אותו שוב בהגדרות → מחלצי מדיה.', + cookieBrowserProfileInvalid: 'נתיב פרופיל הדפדפן שנבחר אינו זמין. בחרו שוב את תיקיית הפרופיל או את מסד הנתונים של Safari בהגדרות → מחלצי מדיה.', youtubeBotBlocked: 'YouTube חסם את הבקשה כאוטומטית. בחר מקור דפדפן מחובר או קובץ עוגיות Netscape ורענן את המטא־נתונים.', youtubeAuthenticationRequired: 'YouTube דורש אימות עבור המדיה הזו. התחבר לחשבון בדפדפן, ייצא עוגיות Netscape או בחר את מקור הדפדפן הנכון ורענן את המטא־נתונים.', youtubePoTokenRequired: 'YouTube דורש PO token תקף עבור הלקוח הזה. נסה מקור דפדפן מחובר או קובץ עוגיות Netscape ורענן את המטא־נתונים.', @@ -1139,6 +1140,13 @@ const he = { mediaExtractors: 'מחלצי מדיה', browserCookiesSource: 'מקור עוגיות דפדפן (Browser Cookies):', cookieDescription: 'תוכן מדיה ציבורי בדרך כלל פועל ללא עוגיות. אם אתר דורש התחברות, בחרו בדפדפן שבו אתם מחוברים; yt-dlp קורא את העוגיות ישירות ממנו, ואין צורך בייצוא ידני. Firelink אינו שומר את תוכן עוגיות הדפדפן.', + browserProfileFolder: 'תיקיית פרופיל דפדפן (אופציונלי):', + browserProfileFolderDescription: 'השאירו ריק כדי להשתמש במיקום ברירת המחדל של yt-dlp. בחרו את התיקייה שמכילה את נתוני הפרופיל של הדפדפן שנבחר. Firelink זוכר נתיב נפרד לכל דפדפן.', + chooseBrowserProfileFolder: 'בחירת תיקיית פרופיל', + safariCookieDatabaseLabel: 'קובץ מסד הנתונים של Safari (אופציונלי):', + safariCookieDatabaseDescription: 'השאירו ריק כדי להשתמש במיקום ברירת המחדל של yt-dlp. בחרו את קובץ מסד הנתונים של Safari.', + chooseSafariCookieDatabase: 'בחירת מסד הנתונים של Safari', + clearBrowserProfilePath: 'ניקוי נתיב הפרופיל', cookieFile: 'קובץ עוגיות Netscape:', cookieFileDescription: 'בדרך כלל השאירו את השדה ריק. כשנבחר דפדפן, Firelink משתמש בקובץ Netscape רק אם אינו מצליח לקרוא את העוגיות מהדפדפן. ללא דפדפן נבחר, הקובץ משמש כמקור העוגיות. Firelink אינו שומר את תוכנו.', chooseCookieFile: 'בחירת קובץ עוגיות', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index b3227a5..7155a85 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -675,6 +675,7 @@ const ru = { playlistNoEntries: 'Плейлист не содержит доступных элементов', invalidMediaMetadata: 'Некорректные метаданные медиа или форматы не найдены', cookieFileInvalid: 'Выбранный файл cookie недоступен или не является обычным файлом. Выберите его снова в настройках → Экстракторы медиа.', + cookieBrowserProfileInvalid: 'Выбранный путь к профилю браузера недоступен. Повторно выберите папку профиля или файл базы cookie Safari в настройках → Экстракторы медиа.', youtubeBotBlocked: 'YouTube заблокировал запрос как автоматический. Выберите браузерный источник с выполненным входом или файл cookie Netscape и обновите метаданные.', youtubeAuthenticationRequired: 'Для этого медиа YouTube требует входа. Войдите в браузер, экспортируйте cookie Netscape или выберите правильный источник браузера и обновите метаданные.', youtubePoTokenRequired: 'Для этого клиента YouTube требует действительный PO-токен. Попробуйте браузерный источник с выполненным входом или файл cookie Netscape и обновите метаданные.', @@ -1139,6 +1140,13 @@ const ru = { mediaExtractors: 'Экстракторы медиа', browserCookiesSource: 'Источник файлов cookie браузера:', cookieDescription: 'Для общедоступных медиа обычно не нужны файлы cookie. Если сайт требует входа, выберите браузер, в котором выполнен вход; yt-dlp читает cookie напрямую из него, поэтому экспортировать их вручную не нужно. Firelink не сохраняет содержимое cookie браузера.', + browserProfileFolder: 'Папка профиля браузера (необязательно):', + browserProfileFolderDescription: 'Оставьте поле пустым, чтобы использовать расположение yt-dlp по умолчанию. Выберите папку с данными профиля выбранного браузера. Firelink запоминает отдельный путь для каждого браузера.', + chooseBrowserProfileFolder: 'Выбрать папку профиля', + safariCookieDatabaseLabel: 'Файл базы cookie Safari (необязательно):', + safariCookieDatabaseDescription: 'Оставьте поле пустым, чтобы использовать расположение yt-dlp по умолчанию. Выберите файл базы cookie Safari.', + chooseSafariCookieDatabase: 'Выбрать базу cookie Safari', + clearBrowserProfilePath: 'Очистить путь профиля', cookieFile: 'Файл cookie Netscape:', cookieFileDescription: 'Обычно оставляйте это поле пустым. Если выбран браузер, Firelink использует файл Netscape, только когда не удаётся прочитать cookie из браузера. Если браузер не выбран, файл становится источником cookie. Firelink не сохраняет его содержимое.', chooseCookieFile: 'Выбрать файл cookie', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 535efb1..2bc65eb 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -675,6 +675,7 @@ const uk = { playlistNoEntries: 'Плейлист не містить елементів для завантаження', invalidMediaMetadata: 'Недійсні метадані медіа або не знайдено форматів', cookieFileInvalid: 'Вибраний файл cookie недоступний або не є звичайним файлом. Виберіть його знову в налаштуваннях → Екстрактори медіа.', + cookieBrowserProfileInvalid: 'Вибраний шлях до профілю браузера недоступний. Повторно виберіть папку профілю або файл бази cookie Safari в налаштуваннях → Екстрактори медіа.', youtubeBotBlocked: 'YouTube заблокував запит як автоматичний. Виберіть браузерне джерело з виконаним входом або файл cookie Netscape й оновіть метадані.', youtubeAuthenticationRequired: 'Для цього медіа YouTube потребує автентифікації. Увійдіть у браузер, експортуйте cookie Netscape або виберіть правильне джерело браузера й оновіть метадані.', youtubePoTokenRequired: 'Для цього клієнта YouTube потребує дійсний PO-токен. Спробуйте браузерне джерело з виконаним входом або файл cookie Netscape й оновіть метадані.', @@ -1139,6 +1140,13 @@ const uk = { mediaExtractors: 'Екстрактори медіа', browserCookiesSource: 'Джерело Cookies з браузера:', cookieDescription: 'Для загальнодоступних медіа зазвичай не потрібні файли cookie. Якщо сайт вимагає входу, виберіть браузер, у якому ви ввійшли; yt-dlp читає файли cookie безпосередньо з нього, тому експортувати їх вручну не потрібно. Firelink не зберігає вміст файлів cookie браузера.', + browserProfileFolder: 'Папка профілю браузера (необов’язково):', + browserProfileFolderDescription: 'Залиште поле порожнім, щоб використовувати розташування yt-dlp за замовчуванням. Виберіть папку з даними профілю вибраного браузера. Firelink запам’ятовує окремий шлях для кожного браузера.', + chooseBrowserProfileFolder: 'Вибрати папку профілю', + safariCookieDatabaseLabel: 'Файл бази cookie Safari (необов’язково):', + safariCookieDatabaseDescription: 'Залиште поле порожнім, щоб використовувати розташування yt-dlp за замовчуванням. Виберіть файл бази cookie Safari.', + chooseSafariCookieDatabase: 'Вибрати базу cookie Safari', + clearBrowserProfilePath: 'Очистити шлях профілю', cookieFile: 'Файл cookie Netscape:', cookieFileDescription: 'Зазвичай залишайте це поле порожнім. Якщо вибрано браузер, Firelink використовує файл Netscape лише тоді, коли не вдається прочитати файли cookie з браузера. Якщо браузер не вибрано, файл стає джерелом файлів cookie. Firelink не зберігає його вміст.', chooseCookieFile: 'Вибрати файл cookie', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index bd30536..196bec2 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -675,6 +675,7 @@ const zhCN = { playlistNoEntries: '播放列表不包含可下载的条目', invalidMediaMetadata: '媒体元数据无效或未找到格式', cookieFileInvalid: '所选 Cookie 文件不可用或不是普通文件。请在设置 → 媒体提取器中重新选择。', + cookieBrowserProfileInvalid: '所选浏览器配置路径不可用。请在设置 → 媒体提取器中重新选择浏览器配置文件夹或 Safari Cookie 数据库文件。', youtubeBotBlocked: 'YouTube 将此请求识别为自动请求并阻止了它。请选择已登录的浏览器来源或 Netscape Cookie 文件,然后刷新元数据。', youtubeAuthenticationRequired: 'YouTube 要求为此媒体进行身份验证。请登录浏览器账户、导出 Netscape Cookie,或选择正确的浏览器来源,然后刷新元数据。', youtubePoTokenRequired: 'YouTube 要求此客户端提供有效的 PO token。请尝试已登录的浏览器来源或 Netscape Cookie 文件,然后刷新元数据。', @@ -1139,6 +1140,13 @@ const zhCN = { mediaExtractors: '媒体提取器', browserCookiesSource: '浏览器 Cookies 来源:', cookieDescription: '公开媒体通常无需 cookies。如果网站要求登录,请选择已登录的浏览器;yt-dlp 会直接读取该浏览器的 cookies,因此无需手动导出。Firelink 不会保存浏览器 cookies 的内容。', + browserProfileFolder: '浏览器配置文件夹(可选):', + browserProfileFolderDescription: '留空即可使用 yt-dlp 的默认位置。选择包含所选浏览器配置数据的文件夹。Firelink 会为每个浏览器分别记住路径。', + chooseBrowserProfileFolder: '选择配置文件夹', + safariCookieDatabaseLabel: 'Safari Cookie 数据库文件(可选):', + safariCookieDatabaseDescription: '留空即可使用 yt-dlp 的默认位置。选择 Safari Cookie 数据库文件。', + chooseSafariCookieDatabase: '选择 Safari Cookie 数据库', + clearBrowserProfilePath: '清除配置路径', cookieFile: 'Netscape Cookie 文件:', cookieFileDescription: '通常留空即可。选择了浏览器后,只有在无法读取浏览器 cookies 时 Firelink 才会尝试此 Netscape 格式文件。未选择浏览器时,此文件会作为 cookies 来源。Firelink 不会保存文件内容。', chooseCookieFile: '选择 Cookie 文件', diff --git a/src/ipc.ts b/src/ipc.ts index de8dc75..c8a5515 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -50,11 +50,11 @@ type CommandMap = { result: MetadataResponse; }; fetch_media_metadata: { - args: { url: string; cookieFile: string | null; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; + args: { url: string; cookieFile: string | null; cookieBrowser: string | null; cookieBrowserProfile: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; result: MediaMetadata; }; fetch_media_playlist_metadata: { - args: { url: string; cookieFile: string | null; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; + args: { url: string; cookieFile: string | null; cookieBrowser: string | null; cookieBrowserProfile: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; result: MediaPlaylistMetadata; }; inspect_torrent: { diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index df20e10..2a5f457 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -3260,6 +3260,7 @@ describe('useDownloadStore', () => { vi.mocked(useSettingsStore.getState).mockReturnValue({ ...useSettingsStore.getState(), mediaCookieSource: 'chrome', + mediaCookieBrowserProfiles: { chrome: '/profiles/chrome-work' }, mediaCookieFile: '/tmp/firelink-cookies.txt' } as unknown as ReturnType); useDownloadStore.setState({ @@ -3296,6 +3297,7 @@ describe('useDownloadStore', () => { id, is_media: true, cookie_source: 'chrome', + cookie_browser_profile: '/profiles/chrome-work', cookie_file: '/tmp/firelink-cookies.txt', }); expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(false); @@ -3309,6 +3311,7 @@ describe('useDownloadStore', () => { vi.mocked(useSettingsStore.getState).mockReturnValue({ ...useSettingsStore.getState(), mediaCookieSource: 'firefox', + mediaCookieBrowserProfiles: { firefox: '/profiles/firefox-work' }, mediaCookieFile: '/tmp/firelink-cookies.txt' } as unknown as ReturnType); useDownloadStore.setState({ @@ -3343,6 +3346,7 @@ describe('useDownloadStore', () => { id, is_media: true, cookie_source: 'firefox', + cookie_browser_profile: '/profiles/firefox-work', cookie_file: '/tmp/firelink-cookies.txt', }) })); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index b275aee..7a416f6 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -552,6 +552,9 @@ async function dispatchItemInternal( cookie_source: item.isMedia === true && hasConfiguredMediaCookieSource(item, settings) ? settings.mediaCookieSource : null, + cookie_browser_profile: item.isMedia === true && settings.mediaCookieSource !== 'none' + ? settings.mediaCookieBrowserProfiles?.[settings.mediaCookieSource]?.trim() || undefined + : undefined, cookie_file: item.isMedia === true && settings.mediaCookieFile?.trim() ? settings.mediaCookieFile.trim() : undefined, @@ -3371,6 +3374,9 @@ export const useDownloadStore = create((set, get) => { cookie_source: item.isMedia === true && hasConfiguredMediaCookieSource(item, settings) ? settings.mediaCookieSource : null, + cookie_browser_profile: item.isMedia === true && settings.mediaCookieSource !== 'none' + ? settings.mediaCookieBrowserProfiles?.[settings.mediaCookieSource]?.trim() || undefined + : undefined, cookie_file: item.isMedia === true && settings.mediaCookieFile?.trim() ? settings.mediaCookieFile.trim() : undefined, diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index 206f8bc..ecb5523 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -288,6 +288,52 @@ describe('durable main-window and sidebar preferences', () => { expect(fallbackResult?.scheduler.selectedQueueIds).toEqual(current.scheduler.selectedQueueIds); }); + it('hydrates legacy browser-cookie settings with no profiles and sanitizes profiles per browser', () => { + const merge = useSettingsStore.persist.getOptions().merge; + expect(merge).toBeTypeOf('function'); + const current = { + ...useSettingsStore.getState(), + mediaCookieBrowserProfiles: { opera: '/stale/current-state-profile' } + }; + + expect(merge?.({ mediaCookieSource: 'opera' }, current)?.mediaCookieBrowserProfiles).toEqual({}); + expect(merge?.({ + mediaCookieBrowserProfiles: { + opera: ' C:\\Users\\Nima\\Opera GX ', + firefox: ' ', + none: '/ignored/none', + 'unknown-browser': '/ignored/unknown', + chrome: 42 + } + }, current)?.mediaCookieBrowserProfiles).toEqual({ + opera: 'C:\\Users\\Nima\\Opera GX' + }); + }); + + it('remembers and clears browser profile folders independently', () => { + const { setMediaCookieBrowserProfile } = useSettingsStore.getState(); + setMediaCookieBrowserProfile('opera', ' C:\\Users\\Nima\\Opera GX '); + setMediaCookieBrowserProfile('firefox', '/profiles/firefox-work'); + + expect(useSettingsStore.getState().mediaCookieBrowserProfiles).toEqual({ + opera: 'C:\\Users\\Nima\\Opera GX', + firefox: '/profiles/firefox-work' + }); + + const partialize = useSettingsStore.persist.getOptions().partialize; + expect(partialize?.(useSettingsStore.getState())).toMatchObject({ + mediaCookieBrowserProfiles: { + opera: 'C:\\Users\\Nima\\Opera GX', + firefox: '/profiles/firefox-work' + } + }); + + setMediaCookieBrowserProfile('opera', ' '); + expect(useSettingsStore.getState().mediaCookieBrowserProfiles).toEqual({ + firefox: '/profiles/firefox-work' + }); + }); + it('filters empty scheduler active download IDs during hydration', () => { const merge = useSettingsStore.persist.getOptions().merge; expect(merge).toBeTypeOf('function'); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 33aee0c..026dfd3 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -213,6 +213,19 @@ const persistedBoolean = (value: unknown, fallback: boolean) => const persistedString = (value: unknown, fallback: string): string => typeof value === 'string' ? value : fallback; +const sanitizeMediaCookieBrowserProfiles = (value: unknown): Record => { + if (!isRecord(value)) return {}; + return Object.fromEntries(Object.entries(value).flatMap(([browser, profile]) => { + if ( + browser === 'none' + || !isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, browser) + || typeof profile !== 'string' + || !profile.trim() + ) return []; + return [[browser, profile.trim()]]; + })); +}; + const sanitizeSchedulerActiveDownloadIds = ( value: unknown, fallback: string[] @@ -377,6 +390,7 @@ export interface SettingsState { preventsSleepWhileDownloading: boolean; preventsDisplaySleepWhileDownloading: boolean; mediaCookieSource: MediaCookieSource; + mediaCookieBrowserProfiles: Record; mediaCookieFile: string; siteLogins: SiteLogin[]; extensionPairingToken: string; @@ -464,6 +478,7 @@ export interface SettingsState { setPreventsSleepWhileDownloading: (prevent: boolean) => void; setPreventsDisplaySleepWhileDownloading: (prevent: boolean) => void; setMediaCookieSource: (source: MediaCookieSource) => void; + setMediaCookieBrowserProfile: (browser: MediaCookieSource, profile: string) => void; setMediaCookieFile: (path: string) => void; setRememberLastUsedDownloadDirectory: (enabled: boolean) => void; setLastUsedDownloadDirectory: (path: string) => void; @@ -571,6 +586,7 @@ export const useSettingsStore = create()( preventsSleepWhileDownloading: true, preventsDisplaySleepWhileDownloading: false, mediaCookieSource: 'none', + mediaCookieBrowserProfiles: {}, mediaCookieFile: '', siteLogins: [], extensionPairingToken: '', @@ -890,6 +906,17 @@ export const useSettingsStore = create()( info('Settings updated: mediaCookieSource'); set({ mediaCookieSource: safe }); }, + setMediaCookieBrowserProfile: (browser, profile) => { + if (browser === 'none' || !isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, browser)) return; + const trimmedProfile = profile.trim(); + info('Settings updated: mediaCookieBrowserProfiles'); + set(state => { + const profiles = { ...state.mediaCookieBrowserProfiles }; + if (trimmedProfile) profiles[browser] = trimmedProfile; + else delete profiles[browser]; + return { mediaCookieBrowserProfiles: profiles }; + }); + }, setMediaCookieFile: (mediaCookieFile) => { info('Settings updated: mediaCookieFile'); set({ mediaCookieFile: mediaCookieFile.trim() }); @@ -1109,6 +1136,7 @@ export const useSettingsStore = create()( preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, preventsDisplaySleepWhileDownloading: state.preventsDisplaySleepWhileDownloading, mediaCookieSource: state.mediaCookieSource, + mediaCookieBrowserProfiles: state.mediaCookieBrowserProfiles, mediaCookieFile: state.mediaCookieFile, siteLogins: state.siteLogins, keychainAccessGranted: state.keychainAccessGranted, @@ -1246,6 +1274,9 @@ export const useSettingsStore = create()( mediaCookieSource: isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, persisted.mediaCookieSource) ? persisted.mediaCookieSource : 'none', + mediaCookieBrowserProfiles: sanitizeMediaCookieBrowserProfiles( + persisted.mediaCookieBrowserProfiles + ), mediaCookieFile: persistedString(persisted.mediaCookieFile, currentState.mediaCookieFile), activeSettingsTab: isAllowedSetting(SETTINGS_TAB_VALUES, persisted.activeSettingsTab) ? persisted.activeSettingsTab diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index eda4c06..3c5e414 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { appendRequestUrlsAfterVersion, applyMediaModeToRow, + classifyMediaMetadataError, commonMediaFormatsForRows, canSubmitMetadataRows, commonMediaQualitiesForRows, @@ -45,6 +46,12 @@ const row = ( }); describe('add download metadata workflow', () => { + it('classifies invalid browser profile folders for translated metadata feedback', () => { + expect(classifyMediaMetadataError( + 'MEDIA_COOKIE_PROFILE_INVALID: browser profile folder is missing or inaccessible' + )).toBe('cookie-profile'); + }); + it('keeps the stable source URL instead of persisting a resolved redirect', () => { const sourceUrl = 'https://github.com/example/project/releases/download/v1/file.zip'; const signedRedirect = 'https://release-assets.githubusercontent.com/github-production-release-asset/asset?se=2099-01-01T00%3A00%3A00Z&sig=redacted'; diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index 59ab519..061f1b2 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -17,6 +17,7 @@ export type MetadataStatus = 'loading' | 'ready' | 'fallback' | 'metadata-error' export type MetadataBlockedReason = | 'unsafe-url' | 'cookie-file' + | 'cookie-profile' | 'youtube-bot' | 'youtube-auth' | 'youtube-po-token'; @@ -24,6 +25,7 @@ export type MetadataBlockedReason = export const classifyMediaMetadataError = (message: string): Exclude | undefined => { const lower = message.toLowerCase(); if (lower.includes('media_cookie_file_invalid')) return 'cookie-file'; + if (lower.includes('media_cookie_profile_invalid')) return 'cookie-profile'; if ( lower.includes('po token') || lower.includes('po_token') diff --git a/src/utils/downloadErrors.test.ts b/src/utils/downloadErrors.test.ts new file mode 100644 index 0000000..8b59e78 --- /dev/null +++ b/src/utils/downloadErrors.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { isInvalidMediaCookieBrowserProfileError } from './downloadErrors'; + +describe('browser cookie profile errors', () => { + it('recognizes the stable native error code', () => { + expect(isInvalidMediaCookieBrowserProfileError( + 'MEDIA_COOKIE_PROFILE_INVALID: browser profile folder is missing or inaccessible' + )).toBe(true); + }); + + it('does not classify unrelated or non-string errors', () => { + expect(isInvalidMediaCookieBrowserProfileError('yt-dlp failed')).toBe(false); + expect(isInvalidMediaCookieBrowserProfileError(undefined)).toBe(false); + }); +}); diff --git a/src/utils/downloadErrors.ts b/src/utils/downloadErrors.ts index 0082657..75a1e76 100644 --- a/src/utils/downloadErrors.ts +++ b/src/utils/downloadErrors.ts @@ -28,3 +28,7 @@ export const classifyDownloadError = (message: unknown): DownloadErrorKind | und } return undefined; }; + +export const isInvalidMediaCookieBrowserProfileError = (message: unknown): boolean => + typeof message === 'string' + && message.toLowerCase().startsWith('media_cookie_profile_invalid:'); diff --git a/src/utils/mediaMetadata.test.ts b/src/utils/mediaMetadata.test.ts new file mode 100644 index 0000000..6f9566f --- /dev/null +++ b/src/utils/mediaMetadata.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../ipc', () => ({ invokeCommand: vi.fn() })); + +import { invokeCommand } from '../ipc'; +import { + fetchMediaMetadataDeduped, + fetchMediaPlaylistMetadataDeduped +} from './mediaMetadata'; +import type { MediaMetadata } from '../bindings/MediaMetadata'; +import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; + +describe('media metadata request deduplication', () => { + beforeEach(() => { + vi.mocked(invokeCommand).mockReset().mockImplementation(() => Promise.resolve({} as never)); + }); + + it('keeps concurrent metadata requests for different browser profiles separate', async () => { + const request = { + url: 'https://example.com/watch?v=1', + cookieFile: null, + cookieBrowser: 'opera', + cookieBrowserProfile: 'C:\\Users\\Nima\\Opera GX Stable', + userAgent: null, + username: null, + password: null, + headers: null, + cookies: null, + proxy: null + }; + + const firstProfileRequest = fetchMediaMetadataDeduped(request); + const sameProfileRequest = fetchMediaMetadataDeduped({ ...request }); + const otherProfileRequest = fetchMediaMetadataDeduped({ + ...request, + cookieBrowserProfile: 'C:\\Users\\Nima\\Opera Stable' + }); + + expect(sameProfileRequest).toBe(firstProfileRequest); + expect(vi.mocked(invokeCommand)).toHaveBeenCalledTimes(2); + expect(vi.mocked(invokeCommand)).toHaveBeenCalledWith( + 'fetch_media_metadata', + expect.objectContaining({ cookieBrowserProfile: request.cookieBrowserProfile }) + ); + expect(vi.mocked(invokeCommand)).toHaveBeenCalledWith( + 'fetch_media_metadata', + expect.objectContaining({ cookieBrowserProfile: 'C:\\Users\\Nima\\Opera Stable' }) + ); + await Promise.all([ + firstProfileRequest as Promise, + otherProfileRequest as Promise + ]); + }); + + it('keeps concurrent playlist requests for different browser profiles separate', async () => { + const request = { + url: 'https://example.com/playlist', + cookieFile: null, + cookieBrowser: 'opera', + cookieBrowserProfile: 'C:\\Users\\Nima\\Opera GX Stable', + userAgent: null, + username: null, + password: null, + headers: null, + cookies: null, + proxy: null + }; + + const gxRequest = fetchMediaPlaylistMetadataDeduped(request); + const stableRequest = fetchMediaPlaylistMetadataDeduped({ + ...request, + cookieBrowserProfile: 'C:\\Users\\Nima\\Opera Stable' + }); + + expect(vi.mocked(invokeCommand)).toHaveBeenCalledTimes(2); + expect(vi.mocked(invokeCommand)).toHaveBeenCalledWith( + 'fetch_media_playlist_metadata', + expect.objectContaining({ cookieBrowserProfile: request.cookieBrowserProfile }) + ); + expect(vi.mocked(invokeCommand)).toHaveBeenCalledWith( + 'fetch_media_playlist_metadata', + expect.objectContaining({ cookieBrowserProfile: 'C:\\Users\\Nima\\Opera Stable' }) + ); + await Promise.all([ + gxRequest as Promise, + stableRequest as Promise + ]); + }); +}); diff --git a/src/utils/mediaMetadata.ts b/src/utils/mediaMetadata.ts index 75a3857..885acf8 100644 --- a/src/utils/mediaMetadata.ts +++ b/src/utils/mediaMetadata.ts @@ -6,6 +6,7 @@ type FetchMediaMetadataArgs = { url: string; cookieFile: string | null; cookieBrowser: string | null; + cookieBrowserProfile: string | null; userAgent: string | null; username: string | null; password: string | null; @@ -22,6 +23,7 @@ const metadataKey = (args: FetchMediaMetadataArgs) => args.url, args.cookieFile, args.cookieBrowser, + args.cookieBrowserProfile, args.userAgent, args.username, args.password,