Spaces:
Running
Running
| 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; | |
| pub struct SearchParams { | |
| pub q: String, | |
| pub limit: u32, | |
| } | |
| fn default_limit() -> u32 { | |
| 5 | |
| } | |
| pub struct DownloadParams { | |
| pub url: String, | |
| pub quality: Option<String>, | |
| pub title: Option<String>, | |
| pub performer: Option<String>, | |
| pub duration: Option<u64>, | |
| } | |
| pub struct Track { | |
| pub id: u64, | |
| pub title: String, | |
| pub permalink: String, | |
| pub permalink_url: String, | |
| pub duration: u64, | |
| pub user: User, | |
| } | |
| pub struct User { | |
| pub username: String, | |
| } | |
| /// /soundcloud/search - поиск треков на SoundCloud через yt-dlp | |
| pub async fn search(Query(params): Query<SearchParams>) -> 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::<u64>().unwrap_or(idx as u64); | |
| // Duration приходит как float (182.276), нужно округлить до целого | |
| match parts[3].parse::<f64>() { | |
| 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<DownloadParams>) -> 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::<String>(); | |
| 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() | |
| } | |