1use anyhow::anyhow;
2use async_trait::async_trait;
3use tokio::sync::mpsc;
4
5use crate::core::direct_downloader;
6use crate::core::ffmpeg;
7use crate::core::redirect;
8use crate::models::media::{DownloadOptions, DownloadResult, MediaInfo, MediaType, VideoQuality};
9use crate::platforms::traits::PlatformDownloader;
10
11const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
12
13pub struct RedditDownloader {
14 client: reqwest::Client,
15}
16
17enum RedditMedia {
18 Video {
19 video_url: String,
20 duration: Option<f64>,
21 },
22 Gif {
23 url: String,
24 },
25 Image {
26 url: String,
27 },
28 Gallery {
29 items: Vec<GalleryItem>,
30 },
31}
32
33struct GalleryItem {
34 url: String,
35 ext: String,
36}
37
38impl Default for RedditDownloader {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44impl RedditDownloader {
45 pub fn new() -> Self {
46 let mut builder = crate::core::http_client::apply_global_proxy(reqwest::Client::builder())
47 .user_agent(USER_AGENT)
48 .timeout(std::time::Duration::from_secs(120))
49 .connect_timeout(std::time::Duration::from_secs(15));
50
51 if let Some(jar) =
52 crate::core::cookie_parser::load_extension_cookies_for_domain("reddit.com")
53 {
54 builder = builder.cookie_provider(jar);
55 }
56
57 let client = builder.build().unwrap_or_default();
58 Self { client }
59 }
60
61 fn extract_post_id(url: &str) -> Option<String> {
62 let parsed = url::Url::parse(url).ok()?;
63 let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
64
65 if segments.len() >= 4 && segments[0] == "r" && segments[2] == "comments" {
66 return Some(segments[3].to_string());
67 }
68
69 if segments.first() == Some(&"comments") {
70 return segments.get(1).map(|s| s.to_string());
71 }
72
73 if segments.first() == Some(&"video") {
74 return segments.get(1).map(|s| s.to_string());
75 }
76
77 None
78 }
79
80 fn extract_subreddit(url: &str) -> Option<String> {
81 let parsed = url::Url::parse(url).ok()?;
82 let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
83 if segments.first() == Some(&"r") {
84 return segments.get(1).map(|s| s.to_string());
85 }
86 None
87 }
88
89 fn is_short_link(url: &str) -> bool {
90 if let Ok(parsed) = url::Url::parse(url) {
91 if let Some(host) = parsed.host_str() {
92 let host = host.to_lowercase();
93 return host == "v.redd.it" || host == "redd.it";
94 }
95 }
96 false
97 }
98
99 fn is_share_link(url: &str) -> bool {
100 if let Ok(parsed) = url::Url::parse(url) {
101 let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
102 return segments.len() >= 4 && segments[0] == "r" && segments[2] == "s";
103 }
104 false
105 }
106
107 async fn resolve_to_canonical(&self, url: &str) -> anyhow::Result<String> {
108 if Self::is_short_link(url) {
109 return redirect::resolve_redirect(&self.client, url).await;
110 }
111
112 if Self::is_share_link(url) {
113 return redirect::resolve_redirect(&self.client, url).await;
114 }
115
116 Ok(url.to_string())
117 }
118
119 async fn fetch_post_data(&self, post_id: &str) -> anyhow::Result<serde_json::Value> {
120 let url = format!("https://www.reddit.com/comments/{}.json", post_id);
121
122 let response = self
123 .client
124 .get(&url)
125 .header("Accept", "application/json")
126 .send()
127 .await?;
128
129 if !response.status().is_success() {
130 return Err(anyhow!("Reddit retornou HTTP {}", response.status()));
131 }
132
133 let json: serde_json::Value = response.json().await?;
134
135 if !json.is_array() {
136 return Err(anyhow!("Post not found"));
137 }
138
139 json.as_array()
140 .and_then(|arr| arr.first())
141 .and_then(|listing| listing.pointer("/data/children/0/data"))
142 .cloned()
143 .ok_or_else(|| anyhow!("Post not found"))
144 }
145
146 fn construct_audio_url(fallback_url: &str) -> Vec<String> {
147 let video = fallback_url.split('?').next().unwrap_or(fallback_url);
148 let mut candidates = Vec::new();
149
150 if video.contains(".mp4") {
151 if let Some(base) = video.split('_').next() {
152 candidates.push(format!("{}_audio.mp4", base));
153 candidates.push(format!("{}_AUDIO_128.mp4", base));
154 }
155 }
156
157 if let Some(dash_pos) = video.find("DASH") {
158 candidates.push(format!("{}audio", &video[..dash_pos]));
159 }
160
161 candidates
162 }
163
164 async fn find_audio_url(&self, fallback_url: &str) -> Option<String> {
165 let candidates = Self::construct_audio_url(fallback_url);
166
167 for candidate in candidates {
168 let resp = tokio::time::timeout(
169 std::time::Duration::from_secs(5),
170 self.client.head(&candidate).send(),
171 )
172 .await;
173
174 if let Ok(Ok(r)) = resp {
175 if r.status().is_success() {
176 return Some(candidate);
177 }
178 }
179 }
180
181 None
182 }
183
184 fn get_resolution_variants(video_url: &str) -> Vec<String> {
185 let resolutions = [
186 "DASH_720.mp4",
187 "DASH_480.mp4",
188 "DASH_360.mp4",
189 "DASH_240.mp4",
190 ];
191 let mut variants = vec![video_url.to_string()];
192 for res in &resolutions {
193 if !video_url.contains(res) {
194 if let Some(base) = video_url.rfind("DASH_") {
195 let mut variant = video_url[..base].to_string();
196 variant.push_str(res);
197 variants.push(variant);
198 }
199 }
200 }
201 variants
202 }
203
204 async fn download_video_with_fallback(
205 &self,
206 video_url: &str,
207 output: &std::path::Path,
208 progress_tx: mpsc::Sender<f64>,
209 ) -> anyhow::Result<u64> {
210 let variants = Self::get_resolution_variants(video_url);
211 let mut last_err = anyhow!("No resolution available");
212
213 for variant in &variants {
214 match direct_downloader::download_direct(
215 &self.client,
216 variant,
217 output,
218 progress_tx.clone(),
219 None,
220 )
221 .await
222 {
223 Ok(bytes) => return Ok(bytes),
224 Err(e) => {
225 last_err = e;
226 let _ = tokio::fs::remove_file(output).await;
227 }
228 }
229 }
230
231 Err(last_err)
232 }
233
234 fn parse_media(data: &serde_json::Value) -> Option<RedditMedia> {
235 let is_gallery = data
236 .get("is_gallery")
237 .and_then(|v| v.as_bool())
238 .unwrap_or(false);
239 if is_gallery {
240 if let Some(gallery) = Self::parse_gallery(data) {
241 return Some(gallery);
242 }
243 }
244
245 if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
246 if url.ends_with(".gif") {
247 return Some(RedditMedia::Gif {
248 url: url.to_string(),
249 });
250 }
251 }
252
253 if let Some(reddit_video) = data.pointer("/secure_media/reddit_video") {
254 let fallback = reddit_video.get("fallback_url").and_then(|v| v.as_str())?;
255 let duration = reddit_video.get("duration").and_then(|v| v.as_f64());
256 let video_url = fallback.split('?').next().unwrap_or(fallback).to_string();
257
258 return Some(RedditMedia::Video {
259 video_url,
260 duration,
261 });
262 }
263
264 if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
265 let is_media = data
266 .get("is_reddit_media_domain")
267 .and_then(|v| v.as_bool())
268 .unwrap_or(false);
269 if is_media
270 || url.contains("i.redd.it")
271 || url.ends_with(".jpg")
272 || url.ends_with(".png")
273 || url.ends_with(".jpeg")
274 {
275 return Some(RedditMedia::Image {
276 url: url.to_string(),
277 });
278 }
279 }
280
281 None
282 }
283
284 fn parse_gallery(data: &serde_json::Value) -> Option<RedditMedia> {
285 let gallery_data = data.get("gallery_data")?.get("items")?.as_array()?;
286 let media_metadata = data.get("media_metadata")?;
287
288 let mut items = Vec::new();
289
290 for item in gallery_data {
291 let media_id = item.get("media_id").and_then(|v| v.as_str())?;
292 let meta = media_metadata.get(media_id)?;
293
294 let mime = meta
295 .get("m")
296 .and_then(|v| v.as_str())
297 .unwrap_or("image/jpeg");
298 let ext = match mime {
299 "image/png" => "png",
300 "image/gif" => "gif",
301 "image/webp" => "webp",
302 _ => "jpg",
303 };
304
305 let url = if let Some(source) = meta.get("s") {
306 source
307 .get("u")
308 .or_else(|| source.get("gif"))
309 .and_then(|v| v.as_str())
310 .map(|u| u.replace("&", "&"))
311 } else {
312 None
313 };
314
315 if let Some(url) = url {
316 items.push(GalleryItem {
317 url,
318 ext: ext.to_string(),
319 });
320 }
321 }
322
323 if items.is_empty() {
324 return None;
325 }
326
327 Some(RedditMedia::Gallery { items })
328 }
329}
330
331#[async_trait]
332impl PlatformDownloader for RedditDownloader {
333 fn name(&self) -> &str {
334 "reddit"
335 }
336
337 fn can_handle(&self, url: &str) -> bool {
338 if let Ok(parsed) = url::Url::parse(url) {
339 if let Some(host) = parsed.host_str() {
340 let host = host.to_lowercase();
341 return host == "reddit.com"
342 || host.ends_with(".reddit.com")
343 || host == "v.redd.it"
344 || host == "redd.it";
345 }
346 }
347 false
348 }
349
350 async fn get_media_info(&self, url: &str) -> anyhow::Result<MediaInfo> {
351 match self.native_get_media_info(url).await {
352 Ok(info) => Ok(info),
353 Err(native_err) => {
354 tracing::warn!(
355 "[reddit] native failed: {}, trying yt-dlp fallback",
356 native_err
357 );
358 self.fallback_ytdlp(url).await.map_err(|_| native_err)
359 }
360 }
361 }
362
363 async fn download(
364 &self,
365 info: &MediaInfo,
366 opts: &DownloadOptions,
367 progress: mpsc::Sender<f64>,
368 ) -> anyhow::Result<DownloadResult> {
369 if let Some(quality) = info.available_qualities.first() {
370 if quality.format == "ytdlp" {
371 let ytdlp_path = crate::core::ytdlp::ensure_ytdlp(None).await?;
372 return crate::core::ytdlp::download_video(
373 &ytdlp_path,
374 &quality.url,
375 &opts.output_dir,
376 None,
377 progress,
378 opts.download_mode.as_deref(),
379 opts.format_id.as_deref(),
380 opts.filename_template.as_deref(),
381 opts.referer.as_deref().or(Some("https://www.reddit.com/")),
382 opts.cancel_token.clone(),
383 None,
384 opts.concurrent_fragments,
385 false,
386 &[],
387 )
388 .await;
389 }
390 }
391
392 self.native_download(info, opts, progress).await
393 }
394}
395
396impl RedditDownloader {
397 async fn fallback_ytdlp(&self, url: &str) -> anyhow::Result<MediaInfo> {
398 let ytdlp_path = crate::core::ytdlp::ensure_ytdlp(None).await?;
399 let json = crate::core::ytdlp::get_video_info(&ytdlp_path, url, &[]).await?;
400 crate::platforms::generic_ytdlp::GenericYtdlpDownloader::parse_video_info(&json)
401 }
402
403 async fn native_get_media_info(&self, url: &str) -> anyhow::Result<MediaInfo> {
404 let canonical = self.resolve_to_canonical(url).await?;
405
406 let post_id = Self::extract_post_id(&canonical)
407 .ok_or_else(|| anyhow!("Could not extract post ID"))?;
408
409 let subreddit = Self::extract_subreddit(&canonical).unwrap_or_default();
410
411 let data = self.fetch_post_data(&post_id).await?;
412
413 let media = Self::parse_media(&data).ok_or_else(|| anyhow!("No media found in post"))?;
414
415 let source_id = if subreddit.is_empty() {
416 post_id.clone()
417 } else {
418 format!("{}_{}", subreddit.to_lowercase(), post_id)
419 };
420
421 let title = format!("reddit_{}", source_id);
422
423 match media {
424 RedditMedia::Video {
425 video_url,
426 duration,
427 } => {
428 let audio = self.find_audio_url(&video_url).await;
429 let mut qualities = vec![VideoQuality {
430 label: "video".to_string(),
431 width: 0,
432 height: 0,
433 url: video_url,
434 format: "mp4".to_string(),
435
436 filesize_bytes: None,
437 }];
438
439 if let Some(audio_url) = audio {
440 qualities.push(VideoQuality {
441 label: "audio".to_string(),
442 width: 0,
443 height: 0,
444 url: audio_url,
445 format: "mp4_audio".to_string(),
446
447 filesize_bytes: None,
448 });
449 }
450
451 Ok(MediaInfo {
452 title,
453 author: subreddit,
454 platform: "reddit".to_string(),
455 duration_seconds: duration,
456 thumbnail_url: None,
457 available_qualities: qualities,
458 media_type: MediaType::Video,
459 file_size_bytes: None,
460 })
461 }
462 RedditMedia::Gif { url: gif_url } => Ok(MediaInfo {
463 title,
464 author: subreddit,
465 platform: "reddit".to_string(),
466 duration_seconds: None,
467 thumbnail_url: None,
468 available_qualities: vec![VideoQuality {
469 label: "original".to_string(),
470 width: 0,
471 height: 0,
472 url: gif_url,
473 format: "gif".to_string(),
474
475 filesize_bytes: None,
476 }],
477 media_type: MediaType::Gif,
478 file_size_bytes: None,
479 }),
480 RedditMedia::Image { url: image_url } => {
481 let ext = if image_url.ends_with(".png") {
482 "png"
483 } else {
484 "jpg"
485 };
486 Ok(MediaInfo {
487 title,
488 author: subreddit,
489 platform: "reddit".to_string(),
490 duration_seconds: None,
491 thumbnail_url: None,
492 available_qualities: vec![VideoQuality {
493 label: "original".to_string(),
494 width: 0,
495 height: 0,
496 url: image_url,
497 format: ext.to_string(),
498
499 filesize_bytes: None,
500 }],
501 media_type: MediaType::Photo,
502 file_size_bytes: None,
503 })
504 }
505 RedditMedia::Gallery { items } => {
506 let qualities: Vec<VideoQuality> = items
507 .into_iter()
508 .enumerate()
509 .map(|(i, item)| VideoQuality {
510 label: format!("media_{}", i + 1),
511 width: 0,
512 height: 0,
513 url: item.url,
514 format: item.ext,
515
516 filesize_bytes: None,
517 })
518 .collect();
519
520 Ok(MediaInfo {
521 title,
522 author: subreddit,
523 platform: "reddit".to_string(),
524 duration_seconds: None,
525 thumbnail_url: None,
526 available_qualities: qualities,
527 media_type: MediaType::Carousel,
528 file_size_bytes: None,
529 })
530 }
531 }
532 }
533
534 async fn native_download(
535 &self,
536 info: &MediaInfo,
537 opts: &DownloadOptions,
538 progress: mpsc::Sender<f64>,
539 ) -> anyhow::Result<DownloadResult> {
540 match info.media_type {
541 MediaType::Video => {
542 let video_quality = info
543 .available_qualities
544 .iter()
545 .find(|q| q.label == "video")
546 .ok_or_else(|| anyhow!("No video URL"))?;
547
548 let audio_quality = info.available_qualities.iter().find(|q| q.label == "audio");
549
550 let has_audio = audio_quality.is_some();
551 let ffmpeg_available = ffmpeg::is_ffmpeg_available().await;
552
553 if has_audio && !ffmpeg_available {
554 tracing::warn!("[reddit] Video has separate audio but FFmpeg is not installed — downloading video without audio");
555 }
556
557 if has_audio {
558 let video_tmp = opts.output_dir.join(format!(
559 "{}_video_tmp.mp4",
560 sanitize_filename::sanitize(&info.title)
561 ));
562 let audio_tmp = opts.output_dir.join(format!(
563 "{}_audio_tmp.mp4",
564 sanitize_filename::sanitize(&info.title)
565 ));
566 let output = opts
567 .output_dir
568 .join(format!("{}.mp4", sanitize_filename::sanitize(&info.title)));
569
570 let _ = progress.send(0.0).await;
571
572 let (vtx, mut vrx) = mpsc::channel::<f64>(8);
573 let progress_video = progress.clone();
574 tokio::spawn(async move {
575 while let Some(p) = vrx.recv().await {
576 let scaled = p * 0.6;
577 let _ = progress_video.send(scaled).await;
578 }
579 });
580
581 let video_bytes = self
582 .download_video_with_fallback(&video_quality.url, &video_tmp, vtx)
583 .await?;
584
585 let _ = progress.send(60.0).await;
586
587 let audio_url = &audio_quality.unwrap().url;
588 let (atx, mut arx) = mpsc::channel::<f64>(8);
589 let progress_audio = progress.clone();
590 tokio::spawn(async move {
591 while let Some(p) = arx.recv().await {
592 let scaled = 60.0 + p * 0.25;
593 let _ = progress_audio.send(scaled).await;
594 }
595 });
596
597 let audio_ok = direct_downloader::download_direct(
598 &self.client,
599 audio_url,
600 &audio_tmp,
601 atx,
602 None,
603 )
604 .await
605 .is_ok();
606
607 let _ = progress.send(85.0).await;
608
609 if audio_ok && ffmpeg_available {
610 ffmpeg::mux_video_audio(&video_tmp, &audio_tmp, &output).await?;
611 let _ = tokio::fs::remove_file(&video_tmp).await;
612 let _ = tokio::fs::remove_file(&audio_tmp).await;
613 let _ = progress.send(100.0).await;
614
615 let file_size = tokio::fs::metadata(&output).await?.len();
616 Ok(DownloadResult {
617 file_path: output,
618 file_size_bytes: file_size,
619 duration_seconds: info.duration_seconds.unwrap_or(0.0),
620 torrent_id: None,
621 })
622 } else {
623 let video_final = opts.output_dir.join(format!(
624 "{}{}.mp4",
625 sanitize_filename::sanitize(&info.title),
626 if !audio_ok { "" } else { "_noaudio" }
627 ));
628 let _ = tokio::fs::rename(&video_tmp, &video_final).await;
629
630 if audio_ok {
631 let audio_final = opts.output_dir.join(format!(
632 "{}_audio.mp4",
633 sanitize_filename::sanitize(&info.title)
634 ));
635 let _ = tokio::fs::rename(&audio_tmp, &audio_final).await;
636 } else {
637 let _ = tokio::fs::remove_file(&audio_tmp).await;
638 }
639
640 let _ = progress.send(100.0).await;
641
642 Ok(DownloadResult {
643 file_path: video_final,
644 file_size_bytes: video_bytes,
645 duration_seconds: info.duration_seconds.unwrap_or(0.0),
646 torrent_id: None,
647 })
648 }
649 } else {
650 let output = opts
651 .output_dir
652 .join(format!("{}.mp4", sanitize_filename::sanitize(&info.title)));
653 let bytes = self
654 .download_video_with_fallback(&video_quality.url, &output, progress)
655 .await?;
656
657 Ok(DownloadResult {
658 file_path: output,
659 file_size_bytes: bytes,
660 duration_seconds: info.duration_seconds.unwrap_or(0.0),
661 torrent_id: None,
662 })
663 }
664 }
665 MediaType::Gif => {
666 let url = &info
667 .available_qualities
668 .first()
669 .ok_or_else(|| anyhow!("Nenhum URL GIF"))?
670 .url;
671 let output = opts
672 .output_dir
673 .join(format!("{}.gif", sanitize_filename::sanitize(&info.title)));
674 let bytes =
675 direct_downloader::download_direct(&self.client, url, &output, progress, None)
676 .await?;
677
678 Ok(DownloadResult {
679 file_path: output,
680 file_size_bytes: bytes,
681 duration_seconds: 0.0,
682 torrent_id: None,
683 })
684 }
685 MediaType::Photo => {
686 let quality = info
687 .available_qualities
688 .first()
689 .ok_or_else(|| anyhow!("Nenhum URL de imagem"))?;
690 let ext = &quality.format;
691 let output = opts.output_dir.join(format!(
692 "{}.{}",
693 sanitize_filename::sanitize(&info.title),
694 ext
695 ));
696 let bytes = direct_downloader::download_direct(
697 &self.client,
698 &quality.url,
699 &output,
700 progress,
701 None,
702 )
703 .await?;
704
705 Ok(DownloadResult {
706 file_path: output,
707 file_size_bytes: bytes,
708 duration_seconds: 0.0,
709 torrent_id: None,
710 })
711 }
712 MediaType::Carousel => {
713 let count = info.available_qualities.len();
714 let mut total_bytes = 0u64;
715 let mut last_path = opts.output_dir.clone();
716
717 for (i, quality) in info.available_qualities.iter().enumerate() {
718 let filename = format!(
719 "{}_{}.{}",
720 sanitize_filename::sanitize(&info.title),
721 i + 1,
722 quality.format,
723 );
724 let output = opts.output_dir.join(&filename);
725 let (tx, _rx) = mpsc::channel(8);
726
727 let bytes = direct_downloader::download_direct(
728 &self.client,
729 &quality.url,
730 &output,
731 tx,
732 None,
733 )
734 .await?;
735
736 total_bytes += bytes;
737 last_path = output;
738
739 let percent = ((i + 1) as f64 / count as f64) * 100.0;
740 let _ = progress.send(percent).await;
741 }
742
743 Ok(DownloadResult {
744 file_path: last_path,
745 file_size_bytes: total_bytes,
746 duration_seconds: 0.0,
747 torrent_id: None,
748 })
749 }
750 _ => Err(anyhow!("Unsupported media type")),
751 }
752 }
753}