Skip to main content

mangofetch_core/platforms/twitter/
mod.rs

1use std::sync::Arc;
2
3use anyhow::anyhow;
4use async_trait::async_trait;
5use tokio::sync::{mpsc, Mutex};
6
7use crate::core::direct_downloader;
8use crate::models::media::{DownloadOptions, DownloadResult, MediaInfo, MediaType, VideoQuality};
9use crate::platforms::traits::PlatformDownloader;
10
11const GRAPHQL_URL: &str = "https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail";
12const TOKEN_URL: &str = "https://api.x.com/1.1/guest/activate.json";
13const BEARER: &str = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
14const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
15
16const TWEET_FEATURES: &str = r#"{"rweb_video_screen_enabled":false,"payments_enabled":false,"rweb_xchat_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_analysis_button_from_backend":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"responsive_web_enhance_cards_enabled":false}"#;
17
18const TWEET_FIELD_TOGGLES: &str = r#"{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"#;
19
20pub struct TwitterDownloader {
21    client: reqwest::Client,
22    guest_token: Arc<Mutex<Option<String>>>,
23}
24
25enum TwitterMedia {
26    Single(TwitterMediaItem),
27    Multiple(Vec<TwitterMediaItem>),
28}
29
30struct TwitterMediaItem {
31    media_type: TwitterMediaType,
32    url: String,
33    extension: String,
34}
35
36enum TwitterMediaType {
37    Video,
38    Photo,
39    AnimatedGif,
40}
41
42impl Default for TwitterDownloader {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl TwitterDownloader {
49    fn manual_cookie_string() -> Option<String> {
50        let settings = crate::models::settings::AppSettings::load_from_disk();
51        let raw = settings.advanced.twitter_manual_cookie;
52        let trimmed = raw.trim();
53        if trimmed.is_empty() {
54            return None;
55        }
56
57        let parsed = crate::core::cookie_parser::parse_cookie_input(trimmed, "");
58        if !parsed.cookie_string.trim().is_empty() {
59            Some(parsed.cookie_string)
60        } else {
61            Some(trimmed.to_string())
62        }
63    }
64
65    fn request_cookie_header(guest_token: &str) -> String {
66        let guest_cookie = format!(
67            "guest_id={}",
68            urlencoding::encode(&format!("v1:{}", guest_token))
69        );
70
71        if let Some(manual) = Self::manual_cookie_string() {
72            format!("{}; {}", guest_cookie, manual)
73        } else {
74            guest_cookie
75        }
76    }
77
78    fn clone_media_array(value: &serde_json::Value) -> Option<Vec<serde_json::Value>> {
79        value.as_array().filter(|items| !items.is_empty()).cloned()
80    }
81
82    fn find_first_array_for_key(
83        value: &serde_json::Value,
84        target_key: &str,
85    ) -> Option<Vec<serde_json::Value>> {
86        match value {
87            serde_json::Value::Object(map) => {
88                if let Some(found) = map
89                    .get(target_key)
90                    .and_then(Self::clone_media_array)
91                    .filter(|items| !items.is_empty())
92                {
93                    return Some(found);
94                }
95
96                for child in map.values() {
97                    if let Some(found) = Self::find_first_array_for_key(child, target_key) {
98                        return Some(found);
99                    }
100                }
101            }
102            serde_json::Value::Array(items) => {
103                for child in items {
104                    if let Some(found) = Self::find_first_array_for_key(child, target_key) {
105                        return Some(found);
106                    }
107                }
108            }
109            _ => {}
110        }
111
112        None
113    }
114
115    fn media_arrays_from_tweet_result(
116        tweet_result: &serde_json::Value,
117    ) -> Option<Vec<serde_json::Value>> {
118        let candidate_paths = [
119            "/legacy/extended_entities/media",
120            "/tweet/legacy/extended_entities/media",
121            "/legacy/retweeted_status_result/result/legacy/extended_entities/media",
122            "/legacy/retweeted_status_result/result/tweet/legacy/extended_entities/media",
123            "/tweet/legacy/retweeted_status_result/result/legacy/extended_entities/media",
124            "/tweet/legacy/retweeted_status_result/result/tweet/legacy/extended_entities/media",
125            "/legacy/quoted_status_result/result/legacy/extended_entities/media",
126            "/legacy/quoted_status_result/result/tweet/legacy/extended_entities/media",
127            "/tweet/legacy/quoted_status_result/result/legacy/extended_entities/media",
128            "/tweet/legacy/quoted_status_result/result/tweet/legacy/extended_entities/media",
129        ];
130
131        for path in candidate_paths {
132            if let Some(items) = tweet_result
133                .pointer(path)
134                .and_then(Self::clone_media_array)
135                .filter(|items| !items.is_empty())
136            {
137                return Some(items);
138            }
139        }
140
141        Self::find_first_array_for_key(tweet_result, "media")
142    }
143
144    fn infer_media_type(media_item: &serde_json::Value) -> Option<TwitterMediaType> {
145        match media_item.get("type").and_then(|v| v.as_str()) {
146            Some("photo") => return Some(TwitterMediaType::Photo),
147            Some("video") => return Some(TwitterMediaType::Video),
148            Some("animated_gif") => return Some(TwitterMediaType::AnimatedGif),
149            _ => {}
150        }
151
152        if media_item
153            .pointer("/video_info/variants")
154            .and_then(|v| v.as_array())
155            .is_some()
156            || media_item
157                .pointer("/video/variants")
158                .and_then(|v| v.as_array())
159                .is_some()
160        {
161            return Some(TwitterMediaType::Video);
162        }
163
164        if media_item
165            .get("media_url_https")
166            .and_then(|v| v.as_str())
167            .is_some()
168            || media_item
169                .get("media_url")
170                .and_then(|v| v.as_str())
171                .is_some()
172        {
173            return Some(TwitterMediaType::Photo);
174        }
175
176        None
177    }
178
179    pub fn new() -> Self {
180        let mut builder = crate::core::http_client::apply_global_proxy(reqwest::Client::builder())
181            .user_agent(USER_AGENT)
182            .timeout(std::time::Duration::from_secs(120))
183            .connect_timeout(std::time::Duration::from_secs(15));
184
185        if let Some(jar) = crate::core::cookie_parser::load_extension_cookies_for_domain("x.com") {
186            builder = builder.cookie_provider(jar);
187        }
188
189        let client = builder.build().unwrap_or_default();
190        Self {
191            client,
192            guest_token: Arc::new(Mutex::new(None)),
193        }
194    }
195
196    fn extract_tweet_id(url: &str) -> Option<String> {
197        let parsed = url::Url::parse(url).ok()?;
198        let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
199
200        if segments.len() >= 3 && segments[1] == "status" {
201            let id = segments[2];
202            if id.chars().all(|c| c.is_ascii_digit()) {
203                return Some(id.to_string());
204            }
205        }
206
207        None
208    }
209
210    async fn get_guest_token(&self, force: bool) -> anyhow::Result<String> {
211        if !force {
212            let cached = self.guest_token.lock().await;
213            if let Some(ref token) = *cached {
214                return Ok(token.clone());
215            }
216        }
217
218        let response = self
219            .client
220            .post(TOKEN_URL)
221            .header("Authorization", BEARER)
222            .header("x-twitter-client-language", "en")
223            .header("x-twitter-active-user", "yes")
224            .header("Accept-Language", "en")
225            .send()
226            .await?;
227
228        if !response.status().is_success() {
229            return Err(anyhow!(
230                "Falha ao obter guest token: HTTP {}",
231                response.status()
232            ));
233        }
234
235        let json: serde_json::Value = response.json().await?;
236        let token = json
237            .get("guest_token")
238            .and_then(|v| v.as_str())
239            .ok_or_else(|| anyhow!("Guest token ausente na resposta"))?
240            .to_string();
241
242        let mut cached = self.guest_token.lock().await;
243        *cached = Some(token.clone());
244        Ok(token)
245    }
246
247    async fn request_tweet(
248        &self,
249        tweet_id: &str,
250        guest_token: &str,
251    ) -> anyhow::Result<serde_json::Value> {
252        let variables = serde_json::json!({
253            "focalTweetId": tweet_id,
254            "with_rux_injections": false,
255            "rankingMode": "Relevance",
256            "includePromotedContent": true,
257            "withCommunity": true,
258            "withQuickPromoteEligibilityTweetFields": true,
259            "withBirdwatchNotes": true,
260            "withVoice": true
261        });
262
263        let url = format!(
264            "{}?variables={}&features={}&fieldToggles={}",
265            GRAPHQL_URL,
266            urlencoding::encode(&variables.to_string()),
267            urlencoding::encode(TWEET_FEATURES),
268            urlencoding::encode(TWEET_FIELD_TOGGLES),
269        );
270
271        let cookie_val = Self::request_cookie_header(guest_token);
272
273        let response = self
274            .client
275            .get(&url)
276            .header("Authorization", BEARER)
277            .header("x-guest-token", guest_token)
278            .header("x-twitter-client-language", "en")
279            .header("x-twitter-active-user", "yes")
280            .header("Accept-Language", "en")
281            .header("Content-Type", "application/json")
282            .header("Cookie", &cookie_val)
283            .send()
284            .await?;
285
286        let status = response.status();
287        tracing::debug!("[twitter] graphql tweet_id={} status={}", tweet_id, status);
288
289        if status == reqwest::StatusCode::FORBIDDEN
290            || status == reqwest::StatusCode::TOO_MANY_REQUESTS
291        {
292            return Err(anyhow!("token_expired"));
293        }
294
295        if status == reqwest::StatusCode::NOT_FOUND {
296            return Err(anyhow!("Post not available"));
297        }
298
299        if !status.is_success() {
300            return Err(anyhow!("Twitter API retornou HTTP {}", status));
301        }
302
303        response.json().await.map_err(Into::into)
304    }
305
306    fn calculate_syndication_token(id: &str) -> String {
307        let num: f64 = id.parse().unwrap_or(0.0);
308        let raw = (num / 1e15) * std::f64::consts::PI;
309        let base36 = Self::f64_to_base36(raw);
310        base36
311            .replace('.', "")
312            .trim_start_matches('0')
313            .trim_end_matches('0')
314            .to_string()
315    }
316
317    fn f64_to_base36(value: f64) -> String {
318        if value == 0.0 {
319            return "0".to_string();
320        }
321
322        let integer_part = value as u64;
323        let fractional_part = value - integer_part as f64;
324
325        let mut int_str = if integer_part == 0 {
326            "0".to_string()
327        } else {
328            let mut n = integer_part;
329            let mut digits = Vec::new();
330            while n > 0 {
331                let rem = (n % 36) as u8;
332                let ch = if rem < 10 {
333                    (b'0' + rem) as char
334                } else {
335                    (b'a' + rem - 10) as char
336                };
337                digits.push(ch);
338                n /= 36;
339            }
340            digits.reverse();
341            digits.into_iter().collect()
342        };
343
344        if fractional_part > 0.0 {
345            int_str.push('.');
346            let mut frac = fractional_part;
347            for _ in 0..12 {
348                frac *= 36.0;
349                let digit = frac as u8;
350                let ch = if digit < 10 {
351                    (b'0' + digit) as char
352                } else {
353                    (b'a' + digit - 10) as char
354                };
355                int_str.push(ch);
356                frac -= digit as f64;
357                if frac <= 0.0 {
358                    break;
359                }
360            }
361        }
362
363        int_str
364    }
365
366    async fn request_syndication(&self, tweet_id: &str) -> anyhow::Result<serde_json::Value> {
367        let token = Self::calculate_syndication_token(tweet_id);
368
369        let url = format!(
370            "https://cdn.syndication.twimg.com/tweet-result?id={}&token={}",
371            tweet_id, token
372        );
373
374        let mut request = self.client.get(&url);
375        if let Some(cookie) = Self::manual_cookie_string() {
376            request = request.header("Cookie", cookie);
377        }
378
379        let response = request.send().await?;
380        tracing::debug!(
381            "[twitter] syndication tweet_id={} token={} status={}",
382            tweet_id,
383            token,
384            response.status()
385        );
386
387        if !response.status().is_success() {
388            return Err(anyhow!(
389                "Syndication API retornou HTTP {}",
390                response.status()
391            ));
392        }
393
394        response.json().await.map_err(Into::into)
395    }
396
397    fn extract_graphql_media(
398        json: &serde_json::Value,
399        tweet_id: &str,
400    ) -> anyhow::Result<Vec<serde_json::Value>> {
401        let instructions = json
402            .pointer("/data/threaded_conversation_with_injections_v2/instructions")
403            .and_then(|v| v.as_array())
404            .ok_or_else(|| anyhow!("Post not available"))?;
405
406        let add_insn = instructions
407            .iter()
408            .find(|i| i.get("type").and_then(|v| v.as_str()) == Some("TimelineAddEntries"))
409            .ok_or_else(|| anyhow!("Post not available"))?;
410
411        let entry_id = format!("tweet-{}", tweet_id);
412        let entries = add_insn
413            .get("entries")
414            .and_then(|v| v.as_array())
415            .ok_or_else(|| anyhow!("Post not available"))?;
416
417        let tweet_result = entries
418            .iter()
419            .find(|e| e.get("entryId").and_then(|v| v.as_str()) == Some(&entry_id))
420            .and_then(|e| e.pointer("/content/itemContent/tweet_results/result"))
421            .ok_or_else(|| anyhow!("Post not available"))?;
422
423        let typename = tweet_result
424            .get("__typename")
425            .and_then(|v| v.as_str())
426            .unwrap_or("");
427        tracing::debug!(
428            "[twitter] graphql media typename={} tweet_id={}",
429            typename,
430            tweet_id
431        );
432
433        match typename {
434            "TweetUnavailable" | "TweetTombstone" => {
435                let reason = tweet_result
436                    .pointer("/result/reason")
437                    .or_else(|| tweet_result.get("reason"))
438                    .and_then(|v| v.as_str())
439                    .unwrap_or("");
440
441                if reason == "Protected" {
442                    return Err(anyhow!("Post privado"));
443                }
444
445                let tombstone_text = tweet_result
446                    .pointer("/tombstone/text/text")
447                    .and_then(|v| v.as_str())
448                    .unwrap_or("");
449
450                tracing::warn!(
451                    "[twitter] graphql tombstone tweet_id={} reason='{}' tombstone_text='{}'",
452                    tweet_id,
453                    reason,
454                    tombstone_text
455                );
456
457                if reason == "NsfwLoggedOut" || tombstone_text.starts_with("Age-restricted") {
458                    return Err(anyhow!("Age-restricted content"));
459                }
460
461                Err(anyhow!("Post not available"))
462            }
463            "Tweet" | "TweetWithVisibilityResults" => {
464                let media = Self::media_arrays_from_tweet_result(tweet_result)
465                    .ok_or_else(|| anyhow!("No media found in tweet"))?;
466                tracing::debug!(
467                    "[twitter] graphql extracted {} media entries for tweet_id={}",
468                    media.len(),
469                    tweet_id
470                );
471                Ok(media)
472            }
473            _ => Err(anyhow!("Post not available")),
474        }
475    }
476
477    fn extract_syndication_media(
478        json: &serde_json::Value,
479    ) -> anyhow::Result<Vec<serde_json::Value>> {
480        let typename = json
481            .get("__typename")
482            .and_then(|v| v.as_str())
483            .unwrap_or("");
484        if typename == "TweetTombstone" || typename == "TweetUnavailable" {
485            tracing::warn!("[twitter] syndication tombstone typename={}", typename);
486            return Err(anyhow!("Post not available"));
487        }
488
489        let media = json
490            .get("mediaDetails")
491            .and_then(Self::clone_media_array)
492            .or_else(|| Self::find_first_array_for_key(json, "mediaDetails"))
493            .ok_or_else(|| anyhow!("No media found in tweet"))?;
494
495        tracing::debug!(
496            "[twitter] syndication extracted {} media entries",
497            media.len()
498        );
499        Ok(media)
500    }
501
502    fn best_video_url(media_item: &serde_json::Value) -> Option<String> {
503        let variants = media_item
504            .pointer("/video_info/variants")
505            .or_else(|| media_item.pointer("/video/variants"))
506            .and_then(|v| v.as_array())?;
507
508        let best_mp4 = variants
509            .iter()
510            .filter(|v| v.get("content_type").and_then(|c| c.as_str()) == Some("video/mp4"))
511            .max_by_key(|v| v.get("bitrate").and_then(|b| b.as_u64()).unwrap_or(0))
512            .and_then(|v| v.get("url").and_then(|u| u.as_str()))
513            .map(|s| s.to_string());
514
515        if best_mp4.is_some() {
516            return best_mp4;
517        }
518
519        variants
520            .iter()
521            .filter_map(|v| v.get("url").and_then(|u| u.as_str()))
522            .find(|url| url.contains(".m3u8") || url.contains("mpegurl"))
523            .or_else(|| {
524                variants
525                    .iter()
526                    .filter_map(|v| v.get("url").and_then(|u| u.as_str()))
527                    .next()
528            })
529            .map(|s| s.to_string())
530    }
531
532    fn best_photo_url(media_item: &serde_json::Value) -> Option<(String, String)> {
533        let base_url = media_item
534            .get("media_url_https")
535            .or_else(|| media_item.get("media_url"))
536            .and_then(|v| v.as_str())?;
537
538        let extension = url::Url::parse(base_url)
539            .ok()
540            .and_then(|u| {
541                u.path_segments()
542                    .and_then(|mut segments| segments.next_back().map(|s| s.to_string()))
543            })
544            .and_then(|filename| filename.rsplit('.').next().map(|ext| ext.to_string()))
545            .filter(|ext| !ext.is_empty())
546            .unwrap_or_else(|| "jpg".to_string());
547
548        let url = if let Ok(mut parsed) = url::Url::parse(base_url) {
549            let existing: Vec<(String, String)> = parsed
550                .query_pairs()
551                .filter(|(key, _)| key != "name")
552                .map(|(key, value)| (key.into_owned(), value.into_owned()))
553                .collect();
554            parsed.set_query(None);
555            {
556                let mut qp = parsed.query_pairs_mut();
557                for (key, value) in existing {
558                    qp.append_pair(&key, &value);
559                }
560                qp.append_pair("name", "orig");
561            }
562            parsed.to_string()
563        } else if base_url.contains('?') {
564            format!("{}&name=orig", base_url)
565        } else {
566            format!("{}?name=orig", base_url)
567        };
568
569        Some((url, extension))
570    }
571
572    fn parse_media_items(media: &[serde_json::Value]) -> anyhow::Result<TwitterMedia> {
573        let items: Vec<TwitterMediaItem> = media
574            .iter()
575            .filter_map(|m| match Self::infer_media_type(m)? {
576                TwitterMediaType::Photo => {
577                    let (url, ext) = Self::best_photo_url(m)?;
578                    Some(TwitterMediaItem {
579                        media_type: TwitterMediaType::Photo,
580                        url,
581                        extension: ext,
582                    })
583                }
584                TwitterMediaType::Video => {
585                    let url = Self::best_video_url(m)?;
586                    let extension = if url.contains(".m3u8") || url.contains("mpegurl") {
587                        "ytdlp"
588                    } else {
589                        "mp4"
590                    };
591                    Some(TwitterMediaItem {
592                        media_type: TwitterMediaType::Video,
593                        url,
594                        extension: extension.to_string(),
595                    })
596                }
597                TwitterMediaType::AnimatedGif => {
598                    let url = Self::best_video_url(m)?;
599                    Some(TwitterMediaItem {
600                        media_type: TwitterMediaType::AnimatedGif,
601                        url,
602                        extension: "mp4".to_string(),
603                    })
604                }
605            })
606            .collect();
607
608        if items.is_empty() {
609            return Err(anyhow!("No media found in tweet"));
610        }
611
612        if items.len() == 1 {
613            Ok(TwitterMedia::Single(items.into_iter().next().unwrap()))
614        } else {
615            Ok(TwitterMedia::Multiple(items))
616        }
617    }
618
619    fn media_type_for_item(item: &TwitterMediaItem) -> MediaType {
620        match item.media_type {
621            TwitterMediaType::Video => MediaType::Video,
622            TwitterMediaType::Photo => MediaType::Photo,
623            TwitterMediaType::AnimatedGif => MediaType::Gif,
624        }
625    }
626
627    fn media_info_from_twitter_media(
628        filename_base: String,
629        twitter_media: TwitterMedia,
630    ) -> MediaInfo {
631        match twitter_media {
632            TwitterMedia::Single(item) => {
633                let media_type = Self::media_type_for_item(&item);
634                MediaInfo {
635                    title: filename_base,
636                    author: String::new(),
637                    platform: "twitter".to_string(),
638                    duration_seconds: None,
639                    thumbnail_url: None,
640                    available_qualities: vec![VideoQuality {
641                        label: "original".to_string(),
642                        width: 0,
643                        height: 0,
644                        url: item.url,
645                        format: item.extension,
646                    }],
647                    media_type,
648                    file_size_bytes: None,
649                }
650            }
651            TwitterMedia::Multiple(items) => {
652                let qualities: Vec<VideoQuality> = items
653                    .iter()
654                    .enumerate()
655                    .map(|(i, item)| VideoQuality {
656                        label: format!("media_{}", i + 1),
657                        width: 0,
658                        height: 0,
659                        url: item.url.clone(),
660                        format: item.extension.clone(),
661                    })
662                    .collect();
663
664                MediaInfo {
665                    title: filename_base,
666                    author: String::new(),
667                    platform: "twitter".to_string(),
668                    duration_seconds: None,
669                    thumbnail_url: None,
670                    available_qualities: qualities,
671                    media_type: MediaType::Carousel,
672                    file_size_bytes: None,
673                }
674            }
675        }
676    }
677}
678
679#[async_trait]
680impl PlatformDownloader for TwitterDownloader {
681    fn name(&self) -> &str {
682        "twitter"
683    }
684
685    fn can_handle(&self, url: &str) -> bool {
686        if let Ok(parsed) = url::Url::parse(url) {
687            if let Some(host) = parsed.host_str() {
688                let host = host.to_lowercase();
689                return host == "twitter.com"
690                    || host.ends_with(".twitter.com")
691                    || host == "x.com"
692                    || host.ends_with(".x.com")
693                    || host == "vxtwitter.com"
694                    || host.ends_with(".vxtwitter.com")
695                    || host == "fixvx.com"
696                    || host.ends_with(".fixvx.com");
697            }
698        }
699        false
700    }
701
702    async fn get_media_info(&self, url: &str) -> anyhow::Result<MediaInfo> {
703        match self.native_get_media_info(url).await {
704            Ok(info) => Ok(info),
705            Err(native_err) => {
706                tracing::warn!(
707                    "[twitter] native failed: {}, trying yt-dlp fallback",
708                    native_err
709                );
710                match self.fallback_ytdlp(url).await {
711                    Ok(info) => Ok(info),
712                    Err(fallback_err) => {
713                        tracing::warn!(
714                            "[twitter] yt-dlp fallback failed after native error: {}",
715                            fallback_err
716                        );
717                        Err(anyhow!(
718                            "Twitter extraction failed. native='{}'; ytdlp='{}'",
719                            native_err,
720                            fallback_err
721                        ))
722                    }
723                }
724            }
725        }
726    }
727
728    async fn download(
729        &self,
730        info: &MediaInfo,
731        opts: &DownloadOptions,
732        progress: mpsc::Sender<f64>,
733    ) -> anyhow::Result<DownloadResult> {
734        if let Some(quality) = info.available_qualities.first() {
735            if quality.format == "ytdlp" {
736                let ytdlp_path = crate::core::ytdlp::ensure_ytdlp(None).await?;
737                let mut extra_flags = Vec::new();
738                if let Some(cookie) = Self::manual_cookie_string() {
739                    extra_flags.push("--add-headers".to_string());
740                    extra_flags.push(format!("Cookie:{}", cookie));
741                }
742                return crate::core::ytdlp::download_video(
743                    &ytdlp_path,
744                    &quality.url,
745                    &opts.output_dir,
746                    None,
747                    progress,
748                    opts.download_mode.as_deref(),
749                    opts.format_id.as_deref(),
750                    opts.filename_template.as_deref(),
751                    opts.referer.as_deref().or(Some("https://x.com/")),
752                    opts.cancel_token.clone(),
753                    None,
754                    opts.concurrent_fragments,
755                    false,
756                    &extra_flags,
757                )
758                .await;
759            }
760        }
761
762        let count = info.available_qualities.len();
763
764        if count == 1 {
765            let quality = info.available_qualities.first().unwrap();
766            let filename = format!(
767                "{}.{}",
768                sanitize_filename::sanitize(&info.title),
769                quality.format
770            );
771            let output = opts.output_dir.join(&filename);
772
773            let bytes = direct_downloader::download_direct(
774                &self.client,
775                &quality.url,
776                &output,
777                progress,
778                None,
779            )
780            .await?;
781
782            return Ok(DownloadResult {
783                file_path: output,
784                file_size_bytes: bytes,
785                duration_seconds: 0.0,
786                torrent_id: None,
787            });
788        }
789
790        let mut total_bytes = 0u64;
791        let mut last_path = opts.output_dir.clone();
792
793        for (i, quality) in info.available_qualities.iter().enumerate() {
794            let filename = format!(
795                "{}_{}.{}",
796                sanitize_filename::sanitize(&info.title),
797                i + 1,
798                quality.format
799            );
800            let output = opts.output_dir.join(&filename);
801            let (tx, _rx) = mpsc::channel(8);
802
803            let bytes =
804                direct_downloader::download_direct(&self.client, &quality.url, &output, tx, None)
805                    .await?;
806
807            total_bytes += bytes;
808            last_path = output;
809
810            let percent = ((i + 1) as f64 / count as f64) * 100.0;
811            let _ = progress.send(percent).await;
812        }
813
814        Ok(DownloadResult {
815            file_path: last_path,
816            file_size_bytes: total_bytes,
817            duration_seconds: 0.0,
818            torrent_id: None,
819        })
820    }
821}
822
823impl TwitterDownloader {
824    async fn fallback_ytdlp(&self, url: &str) -> anyhow::Result<MediaInfo> {
825        let ytdlp_path = crate::core::ytdlp::ensure_ytdlp(None).await?;
826        let mut extra_flags = vec![
827            "--referer".to_string(),
828            "https://x.com/".to_string(),
829            "--add-headers".to_string(),
830            "Referer:https://x.com/".to_string(),
831        ];
832        if let Some(cookie) = Self::manual_cookie_string() {
833            extra_flags.push("--add-headers".to_string());
834            extra_flags.push(format!("Cookie:{}", cookie));
835        }
836        let json = crate::core::ytdlp::get_video_info(&ytdlp_path, url, &extra_flags).await?;
837        crate::platforms::generic_ytdlp::GenericYtdlpDownloader::parse_video_info(&json)
838    }
839
840    async fn native_get_media_info(&self, url: &str) -> anyhow::Result<MediaInfo> {
841        let tweet_id =
842            Self::extract_tweet_id(url).ok_or_else(|| anyhow!("Could not extract tweet ID"))?;
843        tracing::debug!(
844            "[twitter] native_get_media_info tweet_id={} url={}",
845            tweet_id,
846            url
847        );
848
849        let filename_base = format!("twitter_{}", tweet_id);
850
851        let media_items = match self.try_graphql(&tweet_id).await {
852            Ok(items) => items,
853            Err(graphql_err) => {
854                tracing::warn!(
855                    "[twitter] graphql lookup failed for tweet_id={}: {}",
856                    tweet_id,
857                    graphql_err
858                );
859                let syndication = self.request_syndication(&tweet_id).await?;
860                Self::extract_syndication_media(&syndication)?
861            }
862        };
863
864        let twitter_media = Self::parse_media_items(&media_items)?;
865
866        Ok(Self::media_info_from_twitter_media(
867            filename_base,
868            twitter_media,
869        ))
870    }
871
872    async fn try_graphql(&self, tweet_id: &str) -> anyhow::Result<Vec<serde_json::Value>> {
873        let token = self.get_guest_token(false).await?;
874
875        match self.request_tweet(tweet_id, &token).await {
876            Ok(json) => Self::extract_graphql_media(&json, tweet_id),
877            Err(e) if e.to_string() == "token_expired" => {
878                let new_token = self.get_guest_token(true).await?;
879                let json = self.request_tweet(tweet_id, &new_token).await?;
880                Self::extract_graphql_media(&json, tweet_id)
881            }
882            Err(e) => Err(e),
883        }
884    }
885}
886
887// #[cfg(test)]
888// mod tests;