From b7675ae1560c03b06de923908d85038e01220e88 Mon Sep 17 00:00:00 2001 From: SilverAsh <1379523665@qq.com> Date: Wed, 15 Apr 2026 00:34:37 +0800 Subject: [PATCH] Fix: Improve twitter media extraction and manual cookie fallback --- src-tauri/omniget-core/src/core/ytdlp.rs | 91 +++- src-tauri/omniget-core/src/models/settings.rs | 3 + src-tauri/src/lib.rs | 5 + src-tauri/src/platforms/twitter/mod.rs | 434 +++++++++++++----- src/lib/i18n/en.json | 4 +- src/lib/i18n/keys.ts | 2 + src/lib/stores/settings-store.svelte.ts | 1 + src/routes/settings/+page.svelte | 46 ++ 8 files changed, 471 insertions(+), 115 deletions(-) diff --git a/src-tauri/omniget-core/src/core/ytdlp.rs b/src-tauri/omniget-core/src/core/ytdlp.rs index b0de045e..0c4108a0 100644 --- a/src-tauri/omniget-core/src/core/ytdlp.rs +++ b/src-tauri/omniget-core/src/core/ytdlp.rs @@ -15,6 +15,7 @@ use crate::models::media::{DownloadResult, FormatInfo}; type ExtCookiePathFn = Box PathBuf + Send + Sync>; type GlobalCookieFileFn = Box Option + Send + Sync>; type CookiesFromBrowserFn = Box String + Send + Sync>; +type ManualCookieHeaderFn = Box String + Send + Sync>; type ExtRefererFn = Box Option + Send + Sync>; type IncludeAutoSubsFn = Box bool + Send + Sync>; type TranslateMetadataFn = Box Option + Send + Sync>; @@ -24,6 +25,7 @@ type SplitChaptersFn = Box bool + Send + Sync>; static EXT_COOKIE_PATH_FN: OnceLock = OnceLock::new(); static GLOBAL_COOKIE_FILE_FN: OnceLock = OnceLock::new(); static COOKIES_FROM_BROWSER_FN: OnceLock = OnceLock::new(); +static MANUAL_COOKIE_HEADER_FN: OnceLock = OnceLock::new(); static EXT_REFERER_FN: OnceLock = OnceLock::new(); static INCLUDE_AUTO_SUBS_FN: OnceLock = OnceLock::new(); static TRANSLATE_METADATA_FN: OnceLock = OnceLock::new(); @@ -42,6 +44,10 @@ pub fn set_cookies_from_browser_fn(f: impl Fn() -> String + Send + Sync + 'stati let _ = COOKIES_FROM_BROWSER_FN.set(Box::new(f)); } +pub fn set_manual_cookie_header_fn(f: impl Fn() -> String + Send + Sync + 'static) { + let _ = MANUAL_COOKIE_HEADER_FN.set(Box::new(f)); +} + pub fn set_ext_referer_fn(f: impl Fn(&str) -> Option + Send + Sync + 'static) { let _ = EXT_REFERER_FN.set(Box::new(f)); } @@ -142,6 +148,21 @@ fn cookies_from_browser_setting() -> String { .unwrap_or_default() } +fn manual_cookie_header_setting() -> Option { + let raw = MANUAL_COOKIE_HEADER_FN.get().map(|f| f()).unwrap_or_default(); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let parsed = crate::core::cookie_parser::parse_cookie_input(trimmed, ""); + if !parsed.cookie_string.trim().is_empty() { + Some(parsed.cookie_string) + } else { + Some(trimmed.to_string()) + } +} + pub fn ext_cookie_path() -> PathBuf { EXT_COOKIE_PATH_FN .get() @@ -263,6 +284,17 @@ fn proxy_args() -> Vec { } } +fn has_explicit_cookie_header(args: &[String]) -> bool { + args.windows(2).any(|pair| { + pair[0] == "--add-headers" && pair[1].to_ascii_lowercase().starts_with("cookie:") + }) +} + +fn append_cookie_header(args: &mut Vec, cookie_header: &str) { + args.push("--add-headers".to_string()); + args.push(format!("Cookie:{}", cookie_header)); +} + struct YtRateLimiter { semaphore: tokio::sync::Semaphore, last_request_ns: AtomicU64, @@ -840,20 +872,41 @@ pub async fn get_video_info( args.push(extractor_args.to_string()); } - let extension_cookies = extension_cookie_file(); - let global_cf = global_cookie_file(); - if let Some(ref cf) = extension_cookies { + let explicit_cookie_header = has_explicit_cookie_header(extra_flags); + let manual_cookie_header = if explicit_cookie_header { + None + } else { + manual_cookie_header_setting() + }; + let extension_cookies = if manual_cookie_header.is_none() { + extension_cookie_file() + } else { + None + }; + let global_cf = if manual_cookie_header.is_none() { + global_cookie_file() + } else { + None + }; + if let Some(ref cookie_header) = manual_cookie_header { + append_cookie_header(&mut args, cookie_header); + tracing::debug!("[yt-dlp] using manual cookie header from settings"); + } else if let Some(ref cf) = extension_cookies { args.push("--cookies".to_string()); args.push(cf.to_string_lossy().to_string()); } else if let Some(ref cf) = global_cf { args.push("--cookies".to_string()); args.push(cf.clone()); - } else { + } else if !explicit_cookie_header { let cfb = cookies_from_browser_setting(); if !cfb.is_empty() { args.push("--cookies-from-browser".to_string()); args.push(cfb); } + } else { + tracing::debug!( + "[yt-dlp] skipping cookies-from-browser because explicit Cookie header was provided" + ); } args.extend(proxy_args()); @@ -1181,9 +1234,20 @@ pub async fn download_video( std::fs::create_dir_all(output_dir)?; - let global_cookie_file = global_cookie_file(); + let explicit_cookie_header = has_explicit_cookie_header(extra_flags); + let manual_cookie_header = if explicit_cookie_header || cookie_file.is_some() { + None + } else { + manual_cookie_header_setting() + }; + let manual_cookie_enabled = manual_cookie_header.is_some(); + let global_cookie_file = if manual_cookie_enabled { + None + } else { + global_cookie_file() + }; - let ext_cookies = if cookie_file.is_none() && global_cookie_file.is_none() { + let ext_cookies = if cookie_file.is_none() && global_cookie_file.is_none() && !manual_cookie_enabled { extension_cookie_file() } else { None @@ -1194,7 +1258,11 @@ pub async fn download_video( .or_else(|| global_cookie_file.map(std::path::PathBuf::from)) .or(ext_cookies); - let cfb_setting = cookies_from_browser_setting(); + let cfb_setting = if manual_cookie_enabled || explicit_cookie_header { + String::new() + } else { + cookies_from_browser_setting() + }; let mut base_args = vec!["-f".to_string(), format_selector]; base_args.extend(js_runtime_args()); @@ -1230,6 +1298,9 @@ pub async fn download_video( base_args.push("--cookies".to_string()); base_args.push(cf.to_string_lossy().to_string()); } + if let Some(ref cookie_header) = manual_cookie_header { + append_cookie_header(&mut base_args, cookie_header); + } if let Some(ref loc) = ffmpeg_location { base_args.push("--ffmpeg-location".to_string()); @@ -1271,7 +1342,9 @@ pub async fn download_video( let mut use_aria2c = aria2c_path.is_some() && mode != "audio" && effective_cookie_file.is_none() - && cfb_setting.is_empty(); + && cfb_setting.is_empty() + && !manual_cookie_enabled + && !explicit_cookie_header; let effective_ua = ext_user_agent_for_url(url).unwrap_or_else(|| CHROME_UA.to_string()); base_args.extend([ @@ -1347,7 +1420,7 @@ pub async fn download_video( let mut extra_args: Vec = Vec::new(); let mut last_error = String::new(); let mut use_subtitles = should_download_subs; - let mut use_cfb = !cfb_setting.is_empty(); + let mut use_cfb = !cfb_setting.is_empty() && !explicit_cookie_header && !manual_cookie_enabled; let mut format_already_simplified = false; let mut last_was_429 = false; diff --git a/src-tauri/omniget-core/src/models/settings.rs b/src-tauri/omniget-core/src/models/settings.rs index 074219ce..68d86df8 100644 --- a/src-tauri/omniget-core/src/models/settings.rs +++ b/src-tauri/omniget-core/src/models/settings.rs @@ -91,6 +91,8 @@ pub struct AdvancedSettings { pub torrent_listen_port: u16, #[serde(default)] pub cookies_from_browser: String, + #[serde(default)] + pub twitter_manual_cookie: String, } fn default_concurrent_fragments() -> u32 { @@ -199,6 +201,7 @@ impl Default for AppSettings { stagger_delay_ms: 150, torrent_listen_port: 6881, cookies_from_browser: String::new(), + twitter_manual_cookie: String::new(), }, telegram: TelegramSettings::default(), proxy: ProxySettings::default(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd4ca6b1..ba2d9eb7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -135,6 +135,11 @@ pub fn run() { .advanced .cookies_from_browser }); + core::ytdlp::set_manual_cookie_header_fn(|| { + storage::config::load_settings_standalone() + .advanced + .twitter_manual_cookie + }); core::ytdlp::set_ext_referer_fn(|url| { native_host::read_extension_metadata(url).and_then(|m| m.referer) }); diff --git a/src-tauri/src/platforms/twitter/mod.rs b/src-tauri/src/platforms/twitter/mod.rs index df480b85..c6889423 100644 --- a/src-tauri/src/platforms/twitter/mod.rs +++ b/src-tauri/src/platforms/twitter/mod.rs @@ -46,6 +46,132 @@ impl Default for TwitterDownloader { } impl TwitterDownloader { + fn manual_cookie_string() -> Option { + let raw = crate::storage::config::load_settings_standalone() + .advanced + .twitter_manual_cookie; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let parsed = crate::core::cookie_parser::parse_cookie_input(trimmed, ""); + if !parsed.cookie_string.trim().is_empty() { + Some(parsed.cookie_string) + } else { + Some(trimmed.to_string()) + } + } + + fn request_cookie_header(guest_token: &str) -> String { + let guest_cookie = format!( + "guest_id={}", + urlencoding::encode(&format!("v1:{}", guest_token)) + ); + + if let Some(manual) = Self::manual_cookie_string() { + format!("{}; {}", guest_cookie, manual) + } else { + guest_cookie + } + } + + fn clone_media_array(value: &serde_json::Value) -> Option> { + value + .as_array() + .filter(|items| !items.is_empty()) + .cloned() + } + + fn find_first_array_for_key( + value: &serde_json::Value, + target_key: &str, + ) -> Option> { + match value { + serde_json::Value::Object(map) => { + if let Some(found) = map + .get(target_key) + .and_then(Self::clone_media_array) + .filter(|items| !items.is_empty()) + { + return Some(found); + } + + for child in map.values() { + if let Some(found) = Self::find_first_array_for_key(child, target_key) { + return Some(found); + } + } + } + serde_json::Value::Array(items) => { + for child in items { + if let Some(found) = Self::find_first_array_for_key(child, target_key) { + return Some(found); + } + } + } + _ => {} + } + + None + } + + fn media_arrays_from_tweet_result(tweet_result: &serde_json::Value) -> Option> { + let candidate_paths = [ + "/legacy/extended_entities/media", + "/tweet/legacy/extended_entities/media", + "/legacy/retweeted_status_result/result/legacy/extended_entities/media", + "/legacy/retweeted_status_result/result/tweet/legacy/extended_entities/media", + "/tweet/legacy/retweeted_status_result/result/legacy/extended_entities/media", + "/tweet/legacy/retweeted_status_result/result/tweet/legacy/extended_entities/media", + "/legacy/quoted_status_result/result/legacy/extended_entities/media", + "/legacy/quoted_status_result/result/tweet/legacy/extended_entities/media", + "/tweet/legacy/quoted_status_result/result/legacy/extended_entities/media", + "/tweet/legacy/quoted_status_result/result/tweet/legacy/extended_entities/media", + ]; + + for path in candidate_paths { + if let Some(items) = tweet_result + .pointer(path) + .and_then(Self::clone_media_array) + .filter(|items| !items.is_empty()) + { + return Some(items); + } + } + + Self::find_first_array_for_key(tweet_result, "media") + } + + fn infer_media_type(media_item: &serde_json::Value) -> Option { + match media_item.get("type").and_then(|v| v.as_str()) { + Some("photo") => return Some(TwitterMediaType::Photo), + Some("video") => return Some(TwitterMediaType::Video), + Some("animated_gif") => return Some(TwitterMediaType::AnimatedGif), + _ => {} + } + + if media_item + .pointer("/video_info/variants") + .and_then(|v| v.as_array()) + .is_some() + || media_item + .pointer("/video/variants") + .and_then(|v| v.as_array()) + .is_some() + { + return Some(TwitterMediaType::Video); + } + + if media_item.get("media_url_https").and_then(|v| v.as_str()).is_some() + || media_item.get("media_url").and_then(|v| v.as_str()).is_some() + { + return Some(TwitterMediaType::Photo); + } + + None + } + pub fn new() -> Self { let mut builder = crate::core::http_client::apply_global_proxy(reqwest::Client::builder()) .user_agent(USER_AGENT) @@ -138,10 +264,7 @@ impl TwitterDownloader { urlencoding::encode(TWEET_FIELD_TOGGLES), ); - let cookie_val = format!( - "guest_id={}", - urlencoding::encode(&format!("v1:{}", guest_token)) - ); + let cookie_val = Self::request_cookie_header(guest_token); let response = self .client @@ -157,6 +280,7 @@ impl TwitterDownloader { .await?; let status = response.status(); + tracing::debug!("[twitter] graphql tweet_id={} status={}", tweet_id, status); if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::TOO_MANY_REQUESTS @@ -243,7 +367,18 @@ impl TwitterDownloader { tweet_id, token ); - let response = self.client.get(&url).send().await?; + let mut request = self.client.get(&url); + if let Some(cookie) = Self::manual_cookie_string() { + request = request.header("Cookie", cookie); + } + + let response = request.send().await?; + tracing::debug!( + "[twitter] syndication tweet_id={} token={} status={}", + tweet_id, + token, + response.status() + ); if !response.status().is_success() { return Err(anyhow!( @@ -285,6 +420,11 @@ impl TwitterDownloader { .get("__typename") .and_then(|v| v.as_str()) .unwrap_or(""); + tracing::debug!( + "[twitter] graphql media typename={} tweet_id={}", + typename, + tweet_id + ); match typename { "TweetUnavailable" | "TweetTombstone" => { @@ -303,6 +443,13 @@ impl TwitterDownloader { .and_then(|v| v.as_str()) .unwrap_or(""); + tracing::warn!( + "[twitter] graphql tombstone tweet_id={} reason='{}' tombstone_text='{}'", + tweet_id, + reason, + tombstone_text + ); + if reason == "NsfwLoggedOut" || tombstone_text.starts_with("Age-restricted") { return Err(anyhow!("Age-restricted content")); } @@ -310,29 +457,14 @@ impl TwitterDownloader { Err(anyhow!("Post not available")) } "Tweet" | "TweetWithVisibilityResults" => { - let base_tweet = if typename == "TweetWithVisibilityResults" { - tweet_result.pointer("/tweet/legacy") - } else { - tweet_result.get("legacy") - }; - - let base_tweet = base_tweet.ok_or_else(|| anyhow!("Post not available"))?; - - let reposted_media = if typename == "TweetWithVisibilityResults" { - tweet_result - .pointer("/tweet/legacy/retweeted_status_result/result/tweet/legacy/extended_entities/media") - } else { - tweet_result.pointer( - "/legacy/retweeted_status_result/result/legacy/extended_entities/media", - ) - }; - - let media = reposted_media - .or_else(|| base_tweet.pointer("/extended_entities/media")) - .and_then(|v| v.as_array()) + let media = Self::media_arrays_from_tweet_result(tweet_result) .ok_or_else(|| anyhow!("No media found in tweet"))?; - - Ok(media.clone()) + tracing::debug!( + "[twitter] graphql extracted {} media entries for tweet_id={}", + media.len(), + tweet_id + ); + Ok(media) } _ => Err(anyhow!("Post not available")), } @@ -341,53 +473,118 @@ impl TwitterDownloader { fn extract_syndication_media( json: &serde_json::Value, ) -> anyhow::Result> { - let media = json - .get("mediaDetails") - .and_then(|v| v.as_array()) + let typename = json.get("__typename").and_then(|v| v.as_str()).unwrap_or(""); + if typename == "TweetTombstone" || typename == "TweetUnavailable" { + tracing::warn!("[twitter] syndication tombstone typename={}", typename); + return Err(anyhow!("Post not available")); + } + + let media = json.get("mediaDetails") + .and_then(Self::clone_media_array) + .or_else(|| Self::find_first_array_for_key(json, "mediaDetails")) .ok_or_else(|| anyhow!("No media found in tweet"))?; - Ok(media.clone()) + tracing::debug!("[twitter] syndication extracted {} media entries", media.len()); + Ok(media) } fn best_video_url(media_item: &serde_json::Value) -> Option { let variants = media_item .pointer("/video_info/variants") + .or_else(|| media_item.pointer("/video/variants")) .and_then(|v| v.as_array())?; - variants + let best_mp4 = variants .iter() .filter(|v| v.get("content_type").and_then(|c| c.as_str()) == Some("video/mp4")) .max_by_key(|v| v.get("bitrate").and_then(|b| b.as_u64()).unwrap_or(0)) .and_then(|v| v.get("url").and_then(|u| u.as_str())) + .map(|s| s.to_string()); + + if best_mp4.is_some() { + return best_mp4; + } + + variants + .iter() + .filter_map(|v| v.get("url").and_then(|u| u.as_str())) + .find(|url| url.contains(".m3u8") || url.contains("mpegurl")) + .or_else(|| { + variants + .iter() + .filter_map(|v| v.get("url").and_then(|u| u.as_str())) + .next() + }) .map(|s| s.to_string()) } + fn best_photo_url(media_item: &serde_json::Value) -> Option<(String, String)> { + let base_url = media_item + .get("media_url_https") + .or_else(|| media_item.get("media_url")) + .and_then(|v| v.as_str())?; + + let extension = url::Url::parse(base_url) + .ok() + .and_then(|u| { + u.path_segments() + .and_then(|segments| segments.last().map(|s| s.to_string())) + }) + .and_then(|filename| filename.rsplit('.').next().map(|ext| ext.to_string())) + .filter(|ext| !ext.is_empty()) + .unwrap_or_else(|| "jpg".to_string()); + + let url = if let Ok(mut parsed) = url::Url::parse(base_url) { + let existing: Vec<(String, String)> = parsed + .query_pairs() + .filter(|(key, _)| key != "name") + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + parsed.set_query(None); + { + let mut qp = parsed.query_pairs_mut(); + for (key, value) in existing { + qp.append_pair(&key, &value); + } + qp.append_pair("name", "orig"); + } + parsed.to_string() + } else if base_url.contains('?') { + format!("{}&name=orig", base_url) + } else { + format!("{}?name=orig", base_url) + }; + + Some((url, extension)) + } + fn parse_media_items(media: &[serde_json::Value]) -> anyhow::Result { let items: Vec = media .iter() .filter_map(|m| { - let media_type_str = m.get("type").and_then(|v| v.as_str()).unwrap_or(""); - - match media_type_str { - "photo" => { - let base_url = m.get("media_url_https").and_then(|v| v.as_str())?; - let url = format!("{}?name=4096x4096", base_url); - let ext = base_url.rsplit('.').next().unwrap_or("jpg").to_string(); + match Self::infer_media_type(m)? { + TwitterMediaType::Photo => { + let (url, ext) = Self::best_photo_url(m)?; Some(TwitterMediaItem { media_type: TwitterMediaType::Photo, url, extension: ext, }) } - "video" => { + TwitterMediaType::Video => { let url = Self::best_video_url(m)?; + let extension = if url.contains(".m3u8") || url.contains("mpegurl") { + "ytdlp" + } else { + "mp4" + }; Some(TwitterMediaItem { media_type: TwitterMediaType::Video, url, - extension: "mp4".to_string(), + extension: extension.to_string(), }) } - "animated_gif" => { + TwitterMediaType::AnimatedGif => { let url = Self::best_video_url(m)?; Some(TwitterMediaItem { media_type: TwitterMediaType::AnimatedGif, @@ -395,7 +592,6 @@ impl TwitterDownloader { extension: "mp4".to_string(), }) } - _ => None, } }) .collect(); @@ -418,6 +614,54 @@ impl TwitterDownloader { TwitterMediaType::AnimatedGif => MediaType::Gif, } } + + fn media_info_from_twitter_media(filename_base: String, twitter_media: TwitterMedia) -> MediaInfo { + match twitter_media { + TwitterMedia::Single(item) => { + let media_type = Self::media_type_for_item(&item); + MediaInfo { + title: filename_base, + author: String::new(), + platform: "twitter".to_string(), + duration_seconds: None, + thumbnail_url: None, + available_qualities: vec![VideoQuality { + label: "original".to_string(), + width: 0, + height: 0, + url: item.url, + format: item.extension, + }], + media_type, + file_size_bytes: None, + } + } + TwitterMedia::Multiple(items) => { + let qualities: Vec = items + .iter() + .enumerate() + .map(|(i, item)| VideoQuality { + label: format!("media_{}", i + 1), + width: 0, + height: 0, + url: item.url.clone(), + format: item.extension.clone(), + }) + .collect(); + + MediaInfo { + title: filename_base, + author: String::new(), + platform: "twitter".to_string(), + duration_seconds: None, + thumbnail_url: None, + available_qualities: qualities, + media_type: MediaType::Carousel, + file_size_bytes: None, + } + } + } + } } #[async_trait] @@ -451,7 +695,20 @@ impl PlatformDownloader for TwitterDownloader { "[twitter] native failed: {}, trying yt-dlp fallback", native_err ); - self.fallback_ytdlp(url).await.map_err(|_| native_err) + match self.fallback_ytdlp(url).await { + Ok(info) => Ok(info), + Err(fallback_err) => { + tracing::warn!( + "[twitter] yt-dlp fallback failed after native error: {}", + fallback_err + ); + Err(anyhow!( + "Twitter extraction failed. native='{}'; ytdlp='{}'", + native_err, + fallback_err + )) + } + } } } } @@ -465,6 +722,11 @@ impl PlatformDownloader for TwitterDownloader { if let Some(quality) = info.available_qualities.first() { if quality.format == "ytdlp" { let ytdlp_path = crate::core::ytdlp::ensure_ytdlp().await?; + let mut extra_flags = Vec::new(); + if let Some(cookie) = Self::manual_cookie_string() { + extra_flags.push("--add-headers".to_string()); + extra_flags.push(format!("Cookie:{}", cookie)); + } return crate::core::ytdlp::download_video( &ytdlp_path, &quality.url, @@ -479,7 +741,7 @@ impl PlatformDownloader for TwitterDownloader { None, opts.concurrent_fragments, false, - &[], + &extra_flags, ) .await; } @@ -549,19 +811,35 @@ impl PlatformDownloader for TwitterDownloader { impl TwitterDownloader { async fn fallback_ytdlp(&self, url: &str) -> anyhow::Result { let ytdlp_path = crate::core::ytdlp::ensure_ytdlp().await?; - let json = crate::core::ytdlp::get_video_info(&ytdlp_path, url, &[]).await?; + let mut extra_flags = vec![ + "--referer".to_string(), + "https://x.com/".to_string(), + "--add-headers".to_string(), + "Referer:https://x.com/".to_string(), + ]; + if let Some(cookie) = Self::manual_cookie_string() { + extra_flags.push("--add-headers".to_string()); + extra_flags.push(format!("Cookie:{}", cookie)); + } + let json = crate::core::ytdlp::get_video_info(&ytdlp_path, url, &extra_flags).await?; crate::platforms::generic_ytdlp::GenericYtdlpDownloader::parse_video_info(&json) } async fn native_get_media_info(&self, url: &str) -> anyhow::Result { let tweet_id = Self::extract_tweet_id(url).ok_or_else(|| anyhow!("Could not extract tweet ID"))?; + tracing::debug!("[twitter] native_get_media_info tweet_id={} url={}", tweet_id, url); let filename_base = format!("twitter_{}", tweet_id); let media_items = match self.try_graphql(&tweet_id).await { Ok(items) => items, - Err(_) => { + Err(graphql_err) => { + tracing::warn!( + "[twitter] graphql lookup failed for tweet_id={}: {}", + tweet_id, + graphql_err + ); let syndication = self.request_syndication(&tweet_id).await?; Self::extract_syndication_media(&syndication)? } @@ -569,64 +847,7 @@ impl TwitterDownloader { let twitter_media = Self::parse_media_items(&media_items)?; - match twitter_media { - TwitterMedia::Single(item) => { - let media_type = Self::media_type_for_item(&item); - Ok(MediaInfo { - title: filename_base, - author: String::new(), - platform: "twitter".to_string(), - duration_seconds: None, - thumbnail_url: None, - available_qualities: vec![VideoQuality { - label: "original".to_string(), - width: 0, - height: 0, - url: item.url, - format: item.extension, - }], - media_type, - file_size_bytes: None, - }) - } - TwitterMedia::Multiple(items) => { - let has_video = items.iter().any(|i| { - matches!( - i.media_type, - TwitterMediaType::Video | TwitterMediaType::AnimatedGif - ) - }); - - let media_type = if has_video { - MediaType::Video - } else { - MediaType::Carousel - }; - - let qualities: Vec = items - .iter() - .enumerate() - .map(|(i, item)| VideoQuality { - label: format!("media_{}", i + 1), - width: 0, - height: 0, - url: item.url.clone(), - format: item.extension.clone(), - }) - .collect(); - - Ok(MediaInfo { - title: filename_base, - author: String::new(), - platform: "twitter".to_string(), - duration_seconds: None, - thumbnail_url: None, - available_qualities: qualities, - media_type, - file_size_bytes: None, - }) - } - } + Ok(Self::media_info_from_twitter_media(filename_base, twitter_media)) } async fn try_graphql(&self, tweet_id: &str) -> anyhow::Result> { @@ -643,3 +864,6 @@ impl TwitterDownloader { } } } + +#[cfg(test)] +mod tests; diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 3d5cbfc5..172259e5 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -283,7 +283,9 @@ "torrent_listen_port": "Torrent listen port", "torrent_listen_port_desc": "TCP port for incoming BitTorrent connections", "cookies_from_browser": "Browser cookies (advanced)", - "cookies_from_browser_desc": "Only set this if the browser extension isn't working. Most users should leave this empty." + "cookies_from_browser_desc": "Only set this if the browser extension isn't working. Most users should leave this empty.", + "twitter_manual_cookie": "Manual cookie", + "twitter_manual_cookie_desc": "If a download fails, you can paste a manual Cookie header here and try again. This is not limited to Twitter/X. Example: sessionid=...; csrftoken=...; auth_token=..." }, "cat_general": "General", "cat_downloads": "Downloads", diff --git a/src/lib/i18n/keys.ts b/src/lib/i18n/keys.ts index 9d97110b..d3655b19 100644 --- a/src/lib/i18n/keys.ts +++ b/src/lib/i18n/keys.ts @@ -463,6 +463,8 @@ export type TranslationKeys = | 'settings.advanced.title' | 'settings.advanced.torrent_listen_port' | 'settings.advanced.torrent_listen_port_desc' + | 'settings.advanced.twitter_manual_cookie' + | 'settings.advanced.twitter_manual_cookie_desc' | 'settings.appearance.lang_el' | 'settings.appearance.lang_en' | 'settings.appearance.lang_fr' diff --git a/src/lib/stores/settings-store.svelte.ts b/src/lib/stores/settings-store.svelte.ts index c00ce94a..92d85b79 100644 --- a/src/lib/stores/settings-store.svelte.ts +++ b/src/lib/stores/settings-store.svelte.ts @@ -45,6 +45,7 @@ export type AppSettings = { stagger_delay_ms: number; torrent_listen_port: number; cookies_from_browser: string; + twitter_manual_cookie: string; }; telegram: { concurrent_downloads: number; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 80a3d259..487a9154 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -168,6 +168,8 @@ let proxyUsername = $state(""); let proxyPassword = $state(""); let proxyTimer = $state | null>(null); + let twitterManualCookieInput = $state(""); + let twitterManualCookieTimer = $state | null>(null); $effect(() => { if (settings) { @@ -176,6 +178,7 @@ proxyHost = settings.proxy?.host ?? ""; proxyUsername = settings.proxy?.username ?? ""; proxyPassword = settings.proxy?.password ?? ""; + twitterManualCookieInput = settings.advanced?.twitter_manual_cookie ?? ""; } }); @@ -275,6 +278,15 @@ }, 800); } + function handleTwitterManualCookieInput(e: Event) { + const value = (e.target as HTMLTextAreaElement).value; + twitterManualCookieInput = value; + if (twitterManualCookieTimer) clearTimeout(twitterManualCookieTimer); + twitterManualCookieTimer = setTimeout(async () => { + await updateSettings({ advanced: { twitter_manual_cookie: value.trim() } }); + }, 800); + } + const YTDLP_FLAG_CATALOG = [ { flag: "--embed-subs", label: "Embed subtitles" }, { flag: "--write-thumbnail", label: "Save thumbnail" }, @@ -947,6 +959,21 @@ />
+
+
+ {$t('settings.advanced.twitter_manual_cookie')} + {$t('settings.advanced.twitter_manual_cookie_desc')} +
+ +
+
{$t('debug.enable')} @@ -1405,6 +1432,25 @@ outline: none; } + .input-textarea { + width: 100%; + min-height: 76px; + resize: vertical; + padding: calc(var(--padding) / 2); + font-size: 13px; + font-weight: 500; + line-height: 1.45; + background: var(--button-elevated); + border-radius: calc(var(--border-radius) / 2); + color: var(--secondary); + border: 1px solid var(--input-border); + } + + .input-textarea:focus-visible { + border-color: var(--blue); + outline: none; + } + .flag-grid { display: flex; flex-wrap: wrap;