File size: 10,556 Bytes
7acafea
bc351ab
7acafea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce63977
7acafea
 
ce63977
 
 
7acafea
 
 
 
 
ce63977
7acafea
 
ce63977
7acafea
 
 
 
 
 
 
 
ce63977
7acafea
 
 
 
 
ce63977
 
7acafea
 
ce63977
7acafea
 
 
 
ce63977
 
7acafea
 
ce63977
42d01dc
7acafea
42d01dc
 
7acafea
ce63977
 
 
 
6e039bb
 
 
 
 
42d01dc
 
 
 
 
 
 
 
 
 
 
 
 
 
7acafea
42d01dc
 
7acafea
 
 
ce63977
7acafea
 
 
 
 
 
 
 
 
1ac196a
 
 
1feb804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7acafea
 
 
 
 
 
 
 
1feb804
 
 
7acafea
 
 
 
 
1feb804
 
7acafea
 
1feb804
7acafea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1feb804
 
 
 
 
 
 
7acafea
 
 
1ac196a
 
 
 
 
 
 
 
7acafea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ac196a
7acafea
1ac196a
7acafea
 
1ac196a
7acafea
 
 
 
 
 
 
 
1ac196a
7acafea
1feb804
7acafea
1ac196a
 
7acafea
 
 
1feb804
 
 
 
 
 
 
 
1ac196a
7acafea
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
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()
}