deezer / src /soundcloud.rs
recycleactor's picture
Upload soundcloud.rs
1ac196a verified
Raw
History Blame Contribute Delete
10.6 kB
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<String>,
pub title: Option<String>,
pub performer: Option<String>,
pub duration: Option<u64>,
}
#[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<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", // Показать все доступные форматы
&params.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",
&params.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", // Не перезаписываем после обработки
&params.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()
}