use axum::{ extract::Query, http::StatusCode, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use serde_json::json; use std::process::Stdio; use tokio::process::Command; #[derive(Debug, Deserialize)] pub struct SearchParams { pub q: String, #[serde(default = "default_limit")] pub limit: u32, } fn default_limit() -> u32 { 5 } #[derive(Debug, Deserialize)] pub struct DownloadParams { pub url: String, pub quality: Option, pub title: Option, pub performer: Option, pub duration: Option, } #[derive(Debug, Serialize)] pub struct Track { pub id: u64, pub title: String, pub permalink: String, pub permalink_url: String, pub duration: u64, pub user: User, } #[derive(Debug, Serialize)] pub struct User { pub username: String, } /// /soundcloud/search - поиск треков на SoundCloud через yt-dlp pub async fn search(Query(params): Query) -> impl IntoResponse { tracing::info!("SoundCloud search: q='{}' limit={}", params.q, params.limit); // Используем yt-dlp для поиска на SoundCloud // Формат: scsearch5:query означает "искать на SoundCloud 5 результатов по запросу query" let search_query = format!("scsearch{}:{}", params.limit, params.q); let output = match Command::new("yt-dlp") .args([ "--quiet", "--no-warnings", "--print", "%(id)s|||%(title)s|||%(uploader)s|||%(duration)s|||%(webpage_url)s", &search_query, ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await { Ok(out) => out, Err(e) => { tracing::error!("yt-dlp search failed: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Search failed: {}", e) })), ).into_response(); } }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); tracing::error!("yt-dlp search exit: {} stderr: {}", output.status, stderr); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Search failed: exit {}", output.status) })), ).into_response(); } let stdout = String::from_utf8_lossy(&output.stdout); tracing::info!("yt-dlp output: {} lines", stdout.lines().count()); let mut tracks = Vec::new(); for (idx, line) in stdout.lines().enumerate() { tracing::info!("Line {}: {}", idx, line); let parts: Vec<&str> = line.split("|||").collect(); tracing::info!("Parts count: {}", parts.len()); if parts.len() >= 5 { // ID может быть числом или строкой, используем строку как permalink let id_str = parts[0]; let id_num = id_str.parse::().unwrap_or(idx as u64); // Duration приходит как float (182.276), нужно округлить до целого match parts[3].parse::() { Ok(duration_float) => { let duration = duration_float.round() as u64; tracing::info!("Parsed track: id={} title='{}' duration={}s", id_str, parts[1], duration); tracks.push(Track { id: id_num, title: parts[1].to_string(), permalink: id_str.to_string(), permalink_url: parts[4].to_string(), duration: duration * 1000, // в миллисекундах для совместимости user: User { username: parts[2].to_string(), }, }); } Err(e) => { tracing::warn!("Failed to parse duration '{}': {}", parts[3], e); } } } else { tracing::warn!("Line {} has only {} parts, expected at least 5", idx, parts.len()); } } tracing::info!("Found {} tracks", tracks.len()); (StatusCode::OK, Json(json!({ "tracks": tracks }))).into_response() } /// /soundcloud/download - скачивание и отдача трека pub async fn download(Query(params): Query) -> impl IntoResponse { tracing::info!("SoundCloud download: {}", params.url); let quality = params.quality.as_deref().unwrap_or("best"); // Скачиваем ТОЛЬКО MP3 в максимальном доступном качестве // SoundCloud обычно отдаёт 128kbps MP3 (стандарт) или 256kbps (Go+, редко) let format_arg = "bestaudio[ext=mp3]/bestaudio"; tracing::info!("Using format selector: {}", format_arg); // Сначала получаем информацию о доступных форматах для отладки if let Ok(info_output) = Command::new("yt-dlp") .args([ "--quiet", "-F", // Показать все доступные форматы ¶ms.url, ]) .output() .await { let formats_info = String::from_utf8_lossy(&info_output.stdout); tracing::info!("Available formats:\n{}", formats_info); } // Получаем URL thumbnail'а let thumbnail_url = if let Ok(thumb_output) = Command::new("yt-dlp") .args([ "--quiet", "--no-warnings", "--print", "%(thumbnail)s", ¶ms.url, ]) .output() .await { let thumb = String::from_utf8_lossy(&thumb_output.stdout).trim().to_string(); if !thumb.is_empty() && thumb.starts_with("http") { tracing::info!("Found thumbnail: {}", thumb); Some(thumb) } else { None } } else { None }; // Скачиваем во временный файл БЕЗ конвертации - оригинальный формат let temp_file = format!("/tmp/sc_{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos()); // Добавляем расширение .%(ext)s чтобы yt-dlp сам определил правильное расширение let output_template = format!("{}.%(ext)s", temp_file); let output = match Command::new("yt-dlp") .args([ "--quiet", "--no-warnings", "-f", format_arg, "-o", &output_template, "--no-post-overwrites", // Не перезаписываем после обработки ¶ms.url, ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await { Ok(out) => out, Err(e) => { tracing::error!("yt-dlp download failed: {}", e); return (StatusCode::INTERNAL_SERVER_ERROR, "Download failed").into_response(); } }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); tracing::error!("yt-dlp download exit: {} stderr: {}", output.status, stderr); return (StatusCode::INTERNAL_SERVER_ERROR, "Download failed").into_response(); } // Логируем stdout чтобы увидеть что скачал yt-dlp let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.is_empty() { tracing::info!("yt-dlp stdout: {}", stdout); } // Находим скачанный файл (yt-dlp добавил расширение через .%(ext)s) let actual_file = if tokio::fs::metadata(&temp_file).await.is_ok() { temp_file.clone() } else { // Ищем файл с расширением .mp3 let mp3_path = format!("{}.mp3", temp_file); if tokio::fs::metadata(&mp3_path).await.is_ok() { tracing::info!("Found downloaded MP3 file"); mp3_path } else { tracing::error!("Could not find downloaded MP3 file"); return (StatusCode::INTERNAL_SERVER_ERROR, "File not found").into_response(); } }; // Читаем файл let audio_data = match tokio::fs::read(&actual_file).await { Ok(data) => data, Err(e) => { tracing::error!("Failed to read temp file: {}", e); let _ = tokio::fs::remove_file(&actual_file).await; return (StatusCode::INTERNAL_SERVER_ERROR, "Read failed").into_response(); } }; // Удаляем временный файл let _ = tokio::fs::remove_file(&actual_file).await; // Формируем имя файла let filename = if let Some(ref title) = params.title { if let Some(ref performer) = params.performer { format!("{} - {}.mp3", performer, title) } else { format!("{}.mp3", title) } } else { "soundcloud_track.mp3".to_string() }; let filename_safe = filename .replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_") .chars() .take(200) .collect::(); tracing::info!("Sending SoundCloud file: {} ({} bytes, audio/mpeg)", filename_safe, audio_data.len()); let mut response = axum::response::Response::builder() .status(200) .header("Content-Type", "audio/mpeg") .header("Content-Length", audio_data.len().to_string()) .header("Content-Disposition", format!("inline; filename=\"{}\"", filename_safe)) .header("Accept-Ranges", "bytes") .header("Access-Control-Allow-Origin", "*") .header("Cache-Control", "public, max-age=31536000"); // Добавляем URL thumbnail'а в заголовок если есть if let Some(thumb_url) = thumbnail_url { response = response.header("X-Thumbnail-Url", thumb_url); } response .body(axum::body::Body::from(audio_data)) .unwrap() .into_response() }