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