//! Media downloader repository — ports HTTP API results to domain types. //! //! Each method corresponds to a downloader from Shirokami-API. //! All use the shared `http_client()` and proxy-fetch infrastructure. use std::borrow::Cow; use std::collections::HashMap; use std::time::Duration; use aes::cipher::{BlockDecrypt, KeyInit}; use base64::Engine; use crate::domain::entity::downloader::{DownloadResult, MediaItem, MediaType}; use crate::domain::error::ScrapingError; use crate::infrastructure::utils::http_client::http_client; use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, USER_AGENT}; use url::Url; /// Default headers for outbound HTTP requests to external APIs. #[allow(dead_code)] fn api_headers() -> HeaderMap { let mut h = HeaderMap::new(); h.insert( USER_AGENT, HeaderValue::from_static( "Mozilla/5.0 (Linux; Android 15; SM-F958 Build/AP3A.240905.015) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.86 Mobile Safari/537.36", ), ); h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); h } /// Helper: extract a YouTube video ID from a URL. fn extract_youtube_id(url: &str) -> Option { let patterns = [ r"youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})", r"youtu\.be/([a-zA-Z0-9_-]{11})", r"youtube\.com/shorts/([a-zA-Z0-9_-]{11})", r"youtube\.com/embed/([a-zA-Z0-9_-]{11})", r"youtube\.com/v/([a-zA-Z0-9_-]{11})", ]; for pat in &patterns { if let Some(caps) = regex::Regex::new(pat).ok().and_then(|r| r.captures(url)) { if let Some(m) = caps.get(1) { return Some(m.as_str().to_string()); } } } None } /// Helper: parse the video-id portion from TikTok/Douyin URL. fn extract_tiktok_id(url: &str) -> Option { if !url.contains("tiktok.com") && !url.contains("douyin.com") { return None; } // Handle short URLs like vm.tiktok.com/ZM8s5qJ6t — resolve redirect first if url.contains("vm.tiktok.com") || url.contains("vt.tiktok.com") { let re = regex::Regex::new(r"/([A-Za-z0-9_-]+)$").ok()?; let short_code = re .captures(url) .and_then(|c| c.get(1))? .as_str() .to_string(); return Some(short_code); } // TikTok URLs contain an 18-20 digit video ID in the path let re = regex::Regex::new(r"/video/(\d{15,25})").ok()?; let caps = re.captures(url)?; Some(caps.get(1)?.as_str().to_string()) } /// Locate the yt-dlp binary on the system. /// Checks: PATH → /home/code/hermes-agent/.venv/bin/yt-dlp → common locations fn find_ytdlp() -> Option { // Check known locations first let candidates = [ "/home/code/hermes-agent/.venv/bin/yt-dlp", "/usr/local/bin/yt-dlp", "/usr/bin/yt-dlp", "/snap/bin/yt-dlp", "/home/code/.local/bin/yt-dlp", ]; for c in &candidates { if std::path::Path::new(c).exists() { return Some(c.to_string()); } } // Check PATH let paths: Vec<_> = std::env::var_os("PATH") .into_iter() .flat_map(|p| std::env::split_paths(&p).collect::>()) .collect(); for path in &paths { let candidate = path.join("yt-dlp"); if candidate.exists() { return Some(candidate.to_string_lossy().into_owned()); } } None } /// Run yt-dlp --dump-json and return the parsed JSON value. /// Uses spawn + manual stdout reading to avoid pipe buffer truncation /// on large outputs (>64KB on Linux default pipe buffer). async fn run_ytdlp_json( url: &str, extra_args: &[&str], ) -> Result { let ytdlp = find_ytdlp().ok_or_else(|| ScrapingError::Http("yt-dlp binary not found".to_string()))?; let extra_args_owned: Vec = extra_args.iter().map(|s| s.to_string()).collect(); let (stdout_str, stderr_str, exit_code) = tokio::task::spawn_blocking({ let url_owned = url.to_string(); let ytdlp_owned = ytdlp.clone(); move || { let mut cmd_args: Vec = vec![ "--dump-json".to_string(), "--no-warnings".to_string(), "--no-check-certificates".to_string(), "--user-agent".to_string(), "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36".to_string(), ]; cmd_args.extend(extra_args_owned.iter().cloned()); cmd_args.push(url_owned); // Use Stdio::piped() + read_to_string to handle large stdout (64KB+). // Command::output() truncates at pipe buffer size. let mut child = std::process::Command::new(&ytdlp_owned) .args(&cmd_args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| ScrapingError::Http(format!("yt-dlp spawn failed: {}", e)))?; let stdout_file = child.stdout.take().unwrap(); let stderr_file = child.stderr.take().unwrap(); let mut stdout_str = String::new(); let mut stderr_str = String::new(); use std::io::Read; let mut stdout_handle = stdout_file; stdout_handle.read_to_string(&mut stdout_str).unwrap_or_default(); let mut stderr_handle = stderr_file; stderr_handle.read_to_string(&mut stderr_str).unwrap_or_default(); let status = child.wait().map_err(|e| ScrapingError::Http(format!("yt-dlp wait failed: {}", e)))?; Ok((stdout_str, stderr_str, status.code())) } }) .await .map_err(|e| ScrapingError::Http(format!("yt-dlp execution failed: {}", e)))??; if exit_code != Some(0) { return Err(ScrapingError::Http(format!( "yt-dlp failed: {}", stderr_str.trim().lines().last().unwrap_or("unknown error") ))); } // yt-dlp --dump-json outputs one JSON per line per format let json_line = stdout_str .lines() .next() .ok_or_else(|| ScrapingError::Http("yt-dlp produced no output".to_string()))?; serde_json::from_str(json_line) .map_err(|e| ScrapingError::Http(format!("yt-dlp JSON parse failed: {}", e))) } /// Run Playwright-based browser scraper as fallback when yt-dlp is blocked. /// Uses headless Chromium to scrape video URLs from anti-bot-protected sites. async fn run_playwright_scraper( url: &str, platform: &str, ) -> Result { // Locate scrape_media.py robustly: alongside the running binary (Nix store), // the Cargo manifest dir (dev), or a few well-known absolute paths. let exe_dir = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.to_path_buf())); let manifest_script = format!("{}/scrape_media.py", env!("CARGO_MANIFEST_DIR")); let mut candid = vec![ exe_dir.map(|d| d.join("scrape_media.py").to_string_lossy().to_string()), Some(manifest_script), Some("/home/code/scraper/scrape_media.py".to_string()), ]; if let Some(rel) = std::env::var_os("SCRAPER_SCRIPT_DIR") { candid.push(Some(format!("{}/scrape_media.py", rel.to_string_lossy()))); } let scraper_script = candid .into_iter() .flatten() .find(|p| std::path::Path::new(p).exists()) .ok_or_else(|| ScrapingError::Http("scrape_media.py not found".to_string()))?; // Find a Python interpreter that has playwright installed. // The system `python3` may resolve to a different interpreter for the // service user, so probe known venv interpreters first. let python_candidates = [ "/home/code/hermes-agent/.venv/bin/python3", "/usr/bin/python3", "python3", ]; let python_bin = python_candidates .iter() .find(|p| { std::process::Command::new(p) .args(["-c", "import playwright"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) }) .map(|s| s.to_string()) .unwrap_or_else(|| "python3".to_string()); let (stdout_str, stderr_str, exit_code) = tokio::task::spawn_blocking({ let url_owned = url.to_string(); let platform_owned = platform.to_string(); let script_owned = scraper_script.clone(); let python_owned = python_bin.clone(); move || -> Result<(String, String, i32), ScrapingError> { let output = std::process::Command::new(&python_owned) .arg(&script_owned) .arg(&url_owned) .arg(&platform_owned) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .output() .map_err(|e| ScrapingError::Http(format!("playwright spawn failed: {}", e)))?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); Ok((stdout, stderr, output.status.code().unwrap_or(-1))) } }) .await .map_err(|e| ScrapingError::Http(format!("playwright task error: {}", e)))??; if exit_code != 0 { return Err(ScrapingError::Http(format!( "playwright scraper failed: {}", stderr_str.trim().lines().last().unwrap_or("unknown error") ))); } serde_json::from_str(&stdout_str) .map_err(|e| ScrapingError::Http(format!("playwright JSON parse failed: {}", e))) } /// Convert a Playwright scraper JSON result (from scrape_media.py) into DownloadResult. fn playwright_to_download_result(data: &serde_json::Value) -> DownloadResult { let title = data .get("title") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let mut result = DownloadResult::success(title); result.provider = data .get("provider") .and_then(|v| v.as_str()) .map(|s| s.to_string()); if let Some(medias) = data.get("media").and_then(|v| v.as_array()) { for m in medias { let item = MediaItem { url: m .get("url") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), quality: None, file_type: m.get("ext").and_then(|v| v.as_str()).and_then(|e| match e { "mp4" | "m3u8" => Some(MediaType::Video), "mp3" | "m4a" => Some(MediaType::Audio), _ => Some(MediaType::Video), }), extension: m.get("ext").and_then(|v| v.as_str()).map(|s| s.to_string()), thumbnail: None, file_size: None, size_bytes: None, frame_width: None, frame_height: None, note: m .get("content_type") .and_then(|v| v.as_str()) .map(|s| s.to_string()), }; result.media.push(item); } } if result.media.is_empty() { result.message = Some("No download URLs found".to_string()); } result } pub struct DownloaderRepository; impl DownloaderRepository { pub fn new() -> Self { Self } /// Shared client getter — uses the global 30s-timeout client. #[allow(dead_code)] fn client(&self) -> &'static crate::infrastructure::utils::http_client::HttpClient { http_client() } /// All-in-one: auto-detect platform from URL and delegate to specialized /// downloader. Falls back to `downr.org` universal scraper. pub async fn download_all_in_one( url: &str, cookies: Option<&str>, ) -> Result { let platform = crate::application::downloader::detect_platform(url); match platform.as_str() { "instagram" | "facebook" => match Self::download_instagram(url, cookies).await { ok @ Ok(_) => ok, Err(_) => Self::download_facebook(url, cookies).await, }, "tiktok" => Self::download_tiktok(url, cookies).await, "youtube" => match Self::download_youtube(url, "720").await { ok @ Ok(_) => ok, Err(_) => Self::download_youtube_mp3(url).await, }, "spotify" => Self::download_spotify(url, cookies).await, "twitter" => Self::download_twitter(url, cookies).await, "pinterest" => Self::download_pinterest(url).await, "mega" => Self::download_mega(url).await, "terabox" => Self::download_terabox(url).await, "gdrive" => Self::download_gdrive(url).await, "mediafire" => Self::download_mediafire(url).await, "pixeldrain" => Self::download_pixeldrain(url).await, "threads" => Self::download_threads(url, cookies).await, "doodstream" => Self::download_doodstream(url).await, "krakenfiles" => Self::download_krakenfiles(url).await, "danbooru" => Self::download_danbooru(url).await, "soundcloud" => Self::download_soundcloud(url).await, "bilibili" => Self::download_bilibili(url).await, _ => fetch_all_in_one(url).await, } } /// Instagram / Facebook via SnapSave. /// Cookies param accepted for API consistency but not required (SnapSave /// fetches its own). pub async fn download_instagram( url: &str, _cookies: Option<&str>, ) -> Result { fetch_snapsave(url).await } /// Facebook via SnapSave. pub async fn download_facebook( url: &str, _cookies: Option<&str>, ) -> Result { fetch_snapsave(url).await } /// TikTok via tikwm.com. pub async fn download_tiktok( url: &str, _cookies: Option<&str>, ) -> Result { fetch_tiktok(url).await } /// YouTube video via savetube.media. pub async fn download_youtube( url: &str, quality: &str, ) -> Result { fetch_youtube_mp4(url, quality).await } /// YouTube to MP3 via ydlp.yard.id. pub async fn download_youtube_mp3(url: &str) -> Result { fetch_youtube_mp3(url).await } /// Spotify via Spotify API (requires api_key). pub async fn download_spotify( url: &str, api_key: Option<&str>, ) -> Result { let _key = match api_key { Some(k) => k.to_string(), None => std::env::var("SPOTIFY_API_KEY").unwrap_or_default(), }; fetch_spotify(url).await } /// Twitter/X media via api.lrm.tube. pub async fn download_twitter( url: &str, _cookies: Option<&str>, ) -> Result { fetch_twitter(url).await } /// Pinterest media via PinterestDownloader. pub async fn download_pinterest(url: &str) -> Result { fetch_pinterest(url).await } /// MEGA.nz file link resolution. pub async fn download_mega(url: &str) -> Result { fetch_mega(url).await } /// TeraBox / TeraFile direct link extraction. pub async fn download_terabox(url: &str) -> Result { fetch_terabox(url).await } /// Google Drive direct download link. pub async fn download_gdrive(url: &str) -> Result { fetch_gdrive(url).await } /// MediaFire direct download link. pub async fn download_mediafire(url: &str) -> Result { fetch_mediafire(url).await } /// PixelDrain file direct link. pub async fn download_pixeldrain(url: &str) -> Result { fetch_pixeldrain(url).await } /// Meta Threads media extraction. pub async fn download_threads( url: &str, _cookies: Option<&str>, ) -> Result { fetch_threads(url, _cookies).await } /// DoodStream direct link extraction. pub async fn download_doodstream(url: &str) -> Result { fetch_doodstream(url).await } /// KrakenFiles direct download link. pub async fn download_krakenfiles(url: &str) -> Result { fetch_krakenfiles(url).await } /// Danbooru image post extraction. pub async fn download_danbooru(url: &str) -> Result { fetch_danbooru(url).await } /// SoundCloud track via ydlp converter. pub async fn download_soundcloud(url: &str) -> Result { fetch_soundcloud(url).await } /// Bilibili video via b23.tv short link expansion. pub async fn download_bilibili(url: &str) -> Result { fetch_bilibili(url).await } } // ============================================================================ // All-in-One downloader (downr.org) // ============================================================================ pub async fn fetch_all_in_one(url: &str) -> Result { let client = http_client(); let headers = { let mut h = HeaderMap::new(); h.insert("user-agent", HeaderValue::from_static( "Mozilla/5.0 (Linux; Android 15; SM-F958 Build/AP3A.240905.015) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.86 Mobile Safari/537.36" )); h.insert("referer", HeaderValue::from_static("https://downr.org/")); h }; // Step 1: get analytics to obtain cookies let analytics_resp = client .client() .get("https://downr.org/.netlify/functions/analytics") .headers(headers.clone()) .send() .await .map_err(|e| ScrapingError::Http(format!("Analytics fetch failed: {}", e)))?; let cookies: HashMap = analytics_resp .headers() .get_all(reqwest::header::SET_COOKIE) .iter() .filter_map(|v| { let s = v.to_str().ok()?; let parts: Vec<&str> = s.split(';').next()?.splitn(2, '=').collect(); if parts.len() == 2 { Some((parts[0].trim().to_string(), parts[1].trim().to_string())) } else { None } }) .collect(); let cookie_header = cookies .iter() .map(|(k, v)| format!("{}={}", k, v)) .collect::>() .join("; "); // Step 2: post download request let body = serde_json::json!({ "url": url }); let mut req_headers = headers.clone(); if !cookie_header.is_empty() { req_headers.insert( "cookie", HeaderValue::from_str(&cookie_header).unwrap_or_else(|_| HeaderValue::from_static("")), ); } req_headers.insert("content-type", HeaderValue::from_static("application/json")); req_headers.insert("origin", HeaderValue::from_static("https://downr.org")); let resp = client .client() .post("https://downr.org/.netlify/functions/download") .headers(req_headers) .json(&body) .send() .await .map_err(|e| ScrapingError::Http(format!("Download request failed: {}", e)))?; let data: serde_json::Value = resp .json() .await .map_err(|e| ScrapingError::Http(format!("JSON parse failed: {}", e)))?; let mut result = DownloadResult::success( data.get("title") .and_then(|v| v.as_str()) .map(|s| s.to_string()), ); result.author = data.get("author").and_then(|v| { v.get("name") .and_then(|n| n.as_str()) .map(|s| s.to_string()) }); result.thumbnail = data .get("thumbnail") .and_then(|v| v.as_str()) .map(|s| s.to_string()); result.provider = Some("downr".to_string()); if let Some(medias) = data.get("medias").and_then(|v| v.as_array()) { for m in medias { let item = MediaItem { url: m .get("url") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), quality: m .get("quality") .and_then(|v| v.as_str()) .map(|s| s.to_string()), file_type: m .get("type") .and_then(|v| v.as_str()) .and_then(|t| match t { "video" => Some(MediaType::Video), "audio" => Some(MediaType::Audio), "image" => Some(MediaType::Image), _ => Some(MediaType::File), }), extension: m .get("extension") .and_then(|v| v.as_str()) .map(|s| s.to_string()), thumbnail: m .get("thumbnail") .and_then(|v| v.as_str()) .map(|s| s.to_string()), file_size: None, size_bytes: None, frame_width: None, frame_height: None, note: None, }; result.media.push(item); } } Ok(result) } // ============================================================================ // Instagram downloader (snapsave.app) // ============================================================================ /// Detect whether a URL points to an image or video, based on content-type, /// magic bytes, or filename hints. Ports the logic from Shirokami's /// `instagram.js` `detectType` function. #[allow(dead_code)] async fn detect_media_type(url: &str) -> MediaType { // thumb paths are images if regex::Regex::new(r"\/thumb(\?|$)") .ok() .map(|r| r.is_match(url)) .unwrap_or(false) { return MediaType::Image; } // Try HEAD request for content-type let client = http_client(); let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; if let Ok(resp) = client .client() .head(url) .header(USER_AGENT, ua) .timeout(Duration::from_secs(8)) .send() .await { if let Some(ct) = resp .headers() .get(CONTENT_TYPE) .and_then(|h| h.to_str().ok()) { let ct = ct.to_lowercase(); if ct.starts_with("video/") { return MediaType::Video; } if ct.starts_with("image/") { return MediaType::Image; } } } // Magic bytes range GET (first 1KB) if let Ok(resp) = client .client() .get(url) .header("range", "bytes=0-1023") .header(USER_AGENT, ua) .header("accept", "*/*") .send() .await { if let Ok(bytes) = resp.bytes().await { if bytes.len() >= 12 { // JPEG: FF D8 FF if bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff { return MediaType::Image; } // PNG if bytes[0..8] == [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] { return MediaType::Image; } // WEBP: RIFF....WEBP if &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { return MediaType::Image; } // MP4: bytes 4..7 = 'ftyp' if &bytes[4..8] == b"ftyp" { return MediaType::Video; } } } } // filename hints if let Ok(parsed) = Url::parse(url) { let query = parsed.query().unwrap_or(""); for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { if (k == "filename" || k == "file") && v.ends_with(".mp4") { return MediaType::Video; } if (k == "filename" || k == "file") && v.ends_with(".jpg") { return MediaType::Image; } } } MediaType::Image } /// SnapSave parser — extracts Instagram/Facebook media via snapsave.app. /// Uses downr.org as fallback for robustness, then Playwright browser scraping. pub(crate) async fn fetch_snapsave(url: &str) -> Result { // Validate Instagram/Facebook URL let valid_fb = regex::Regex::new(r"https?://(web\.|www\.|m\.)(facebook|fb)\.(com|watch)\S+") .unwrap() .is_match(url); let valid_ig = regex::Regex::new(r"https?://(www\.)?instagram\.com/(p|reel|reels|tv|stories)/\S+") .unwrap() .is_match(url); if !valid_fb && !valid_ig { return Ok(DownloadResult::error( "Link Url not valid — only Instagram and Facebook URLs are supported", )); } // Try downr.org first match fetch_all_in_one(url).await { Ok(result) if !result.media.is_empty() => return Ok(result), Ok(_) => {} Err(e) => { eprintln!( "downr.org failed for {}: {}, trying Playwright fallback", url, e ); } } // Fallback: use Playwright browser scraping to extract video URLs let platform = if valid_ig { "instagram" } else { "facebook" }; let data = run_playwright_scraper(url, platform).await?; // Build result from Playwright scraper output let title = data .get("title") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let mut result = DownloadResult::success(title); result.provider = data .get("provider") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let media_arr = data.get("media").and_then(|v| v.as_array()); if let Some(medias) = media_arr { for m in medias { let item = MediaItem { url: m .get("url") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), quality: None, file_type: m.get("ext").and_then(|v| v.as_str()).and_then(|e| match e { "mp4" | "m3u8" => Some(MediaType::Video), "mp3" | "m4a" => Some(MediaType::Audio), _ => Some(MediaType::Video), }), extension: m.get("ext").and_then(|v| v.as_str()).map(|s| s.to_string()), thumbnail: None, file_size: None, size_bytes: None, frame_width: None, frame_height: None, note: None, }; result.media.push(item); } } if result.media.is_empty() { return Ok(DownloadResult::error(format!( "Failed to extract media from {} — server IP may be blocked by anti-bot protection", platform ))); } Ok(result) } /// TikTok via embed-page scraping (primary method). /// Scrapes `https://www.tiktok.com/embed/v2/{video_id}` HTML and extracts the /// direct `v16m.tiktokcdn.com` MP4 URL from the `