1use crate::types::{MonoEvent, SearchKind};
42use base64::Engine as _;
43use serde_json::Value;
44
45pub struct MonoClient {
47 client: reqwest::Client,
48 base_url: String,
49}
50
51impl MonoClient {
52 pub fn new(base_url: impl Into<String>) -> Self {
53 Self {
54 client: reqwest::Client::builder()
55 .user_agent("plexus-mono/0.1.0")
56 .timeout(std::time::Duration::from_secs(15))
57 .build()
58 .expect("failed to build reqwest client"),
59 base_url: base_url.into(),
60 }
61 }
62
63 pub fn default_instance() -> Self {
64 Self::new("https://api.monochrome.tf")
65 }
66
67 async fn get(&self, path: &str) -> Result<Value, String> {
68 let url = format!("{}{}", self.base_url, path);
69 tracing::debug!("GET {}", url);
70 let resp = self
71 .client
72 .get(&url)
73 .send()
74 .await
75 .map_err(|e| format!("request failed: {e}"))?;
76
77 let status = resp.status();
78 if !status.is_success() {
79 return Err(format!("HTTP {status} from {url}"));
80 }
81
82 resp.json::<Value>()
83 .await
84 .map_err(|e| format!("failed to parse JSON: {e}"))
85 }
86
87 pub async fn stream_manifest(&self, id: u64, quality: &str) -> Result<MonoEvent, String> {
96 let json = self.get(&format!("/track/?id={id}&quality={quality}")).await?;
97 let data = &json["data"];
98
99 let manifest_b64 = data["manifest"].as_str()
101 .ok_or("missing manifest field")?;
102 let manifest_bytes = base64::engine::general_purpose::STANDARD
103 .decode(manifest_b64)
104 .map_err(|e| format!("base64 decode failed: {e}"))?;
105 let manifest: Value = serde_json::from_slice(&manifest_bytes)
106 .map_err(|e| format!("manifest JSON parse failed: {e}"))?;
107
108 let mime_type = s(&manifest["mimeType"]);
109 let codecs = s(&manifest["codecs"]);
110 let url = manifest["urls"]
111 .as_array()
112 .and_then(|a| a.first())
113 .and_then(|u| u.as_str())
114 .ok_or("no URLs in manifest")?
115 .to_string();
116
117 let extension = mime_to_ext(&mime_type);
118 let bit_depth = data["bitDepth"].as_u64().map(|n| n as u32);
119 let sample_rate = data["sampleRate"].as_u64().map(|n| n as u32);
120 let actual_quality = s(&data["audioQuality"]);
121
122 Ok(MonoEvent::StreamManifest {
123 id,
124 url,
125 mime_type,
126 codecs,
127 quality: actual_quality,
128 bit_depth,
129 sample_rate,
130 extension,
131 })
132 }
133
134 pub async fn download(
139 &self,
140 id: u64,
141 quality: &str,
142 path: &str,
143 ) -> Result<Vec<MonoEvent>, String> {
144 use futures::StreamExt;
145 use tokio::io::AsyncWriteExt;
146
147 let manifest = self.stream_manifest(id, quality).await?;
149 let (url, mime_type) = match &manifest {
150 MonoEvent::StreamManifest { url, mime_type, .. } => {
151 (url.clone(), mime_type.clone())
152 }
153 _ => return Err("unexpected event from stream_manifest".to_string()),
154 };
155
156 let resp = self.client
158 .get(&url)
159 .send()
160 .await
161 .map_err(|e| format!("download request failed: {e}"))?;
162
163 if !resp.status().is_success() {
164 return Err(format!("download HTTP {}", resp.status()));
165 }
166
167 let total_bytes = resp.content_length();
168 let mut file = tokio::fs::File::create(path)
169 .await
170 .map_err(|e| format!("failed to create {path}: {e}"))?;
171
172 let mut stream = resp.bytes_stream();
173 let mut bytes_downloaded: u64 = 0;
174 let mut events = vec![manifest];
175
176 events.push(MonoEvent::DownloadProgress {
178 path: path.to_string(),
179 bytes_downloaded: 0,
180 total_bytes,
181 percent: Some(0.0),
182 });
183
184 const CHUNK_REPORT: u64 = 256 * 1024; let mut since_last_report: u64 = 0;
186
187 while let Some(chunk) = stream.next().await {
188 let chunk = chunk.map_err(|e| format!("stream error: {e}"))?;
189 file.write_all(&chunk)
190 .await
191 .map_err(|e| format!("write error: {e}"))?;
192
193 bytes_downloaded += chunk.len() as u64;
194 since_last_report += chunk.len() as u64;
195
196 if since_last_report >= CHUNK_REPORT {
197 since_last_report = 0;
198 let percent = total_bytes
199 .map(|t| (bytes_downloaded as f32 / t as f32) * 100.0);
200 events.push(MonoEvent::DownloadProgress {
201 path: path.to_string(),
202 bytes_downloaded,
203 total_bytes,
204 percent,
205 });
206 }
207 }
208
209 file.flush().await.map_err(|e| format!("flush error: {e}"))?;
210
211 events.push(MonoEvent::DownloadComplete {
212 path: path.to_string(),
213 bytes: bytes_downloaded,
214 mime_type,
215 });
216
217 Ok(events)
218 }
219
220 pub async fn track_info(&self, id: u64) -> Result<MonoEvent, String> {
223 let json = self.get(&format!("/info/?id={id}")).await?;
224 let data = &json["data"];
226 parse_track(data).ok_or_else(|| format!("could not parse track {id}"))
227 }
228
229 pub async fn album(&self, id: u64) -> Result<(MonoEvent, Vec<MonoEvent>), String> {
232 let json = self.get(&format!("/album/?id={id}")).await?;
233 let data = &json["data"];
235
236 let album_id = data["id"].as_u64().unwrap_or(id);
237 let title = s(&data["title"]);
238 let artist = s(&data["artist"]["name"]);
239 let release_date = data["releaseDate"].as_str().map(str::to_string);
240 let track_count = data["numberOfTracks"].as_u64().unwrap_or(0) as u32;
241 let duration_secs = data["duration"].as_u64();
242 let cover_id = data["cover"].as_str().map(str::to_string);
243
244 let album = MonoEvent::Album {
245 id: album_id,
246 title,
247 artist,
248 release_date,
249 track_count,
250 duration_secs,
251 cover_id,
252 };
253
254 let tracks: Vec<MonoEvent> = data["items"]
255 .as_array()
256 .map(|arr| {
257 arr.iter()
258 .enumerate()
259 .filter_map(|(i, entry)| {
260 let track = &entry["item"];
262 let t = parse_track(track)?;
263 if let MonoEvent::Track {
264 id,
265 title,
266 artist,
267 duration_secs,
268 audio_quality,
269 ..
270 } = t
271 {
272 Some(MonoEvent::AlbumTrack {
273 position: (i + 1) as u32,
274 id,
275 title,
276 artist,
277 duration_secs,
278 audio_quality,
279 })
280 } else {
281 None
282 }
283 })
284 .collect()
285 })
286 .unwrap_or_default();
287
288 Ok((album, tracks))
289 }
290
291 pub async fn artist(&self, id: u64) -> Result<MonoEvent, String> {
294 let json = self.get(&format!("/artist/?id={id}")).await?;
295 let a = &json["artist"];
298 let artist_id = a["id"].as_u64().unwrap_or(id);
299 let name = s(&a["name"]);
300 let picture_id = a["picture"].as_str().map(str::to_string);
301
302 let cover_url = json["cover"]["750"].as_str().map(str::to_string);
305
306 Ok(MonoEvent::Artist {
307 id: artist_id,
308 name,
309 picture_id,
310 cover_url,
311 })
312 }
313
314 pub async fn search(
317 &self,
318 query: &str,
319 kind: &SearchKind,
320 limit: u32,
321 offset: u32,
322 ) -> Result<Vec<MonoEvent>, String> {
323 let encoded = url_encode(query);
324 let (param, _label) = match kind {
325 SearchKind::Tracks => ("s", "tracks"),
326 SearchKind::Albums => ("al", "albums"),
327 SearchKind::Artists => ("a", "artists"),
328 };
329 let path = format!("/search/?{param}={encoded}&limit={limit}&offset={offset}");
330 let json = self.get(&path).await?;
331 let data = &json["data"];
332
333 match kind {
334 SearchKind::Tracks => {
335 let items = data["items"].as_array().ok_or("missing data.items")?;
337 Ok(items
338 .iter()
339 .enumerate()
340 .filter_map(|(rank, item)| {
341 let t = parse_track(item)?;
342 if let MonoEvent::Track {
343 id,
344 title,
345 artist,
346 album,
347 duration_secs,
348 audio_quality,
349 ..
350 } = t
351 {
352 Some(MonoEvent::SearchTrack {
353 rank: rank as u32,
354 id,
355 title,
356 artist,
357 album,
358 duration_secs,
359 audio_quality,
360 })
361 } else {
362 None
363 }
364 })
365 .collect())
366 }
367 SearchKind::Albums => {
368 let items = data["albums"]["items"]
370 .as_array()
371 .ok_or("missing data.albums.items")?;
372 Ok(items
373 .iter()
374 .enumerate()
375 .map(|(rank, item)| {
376 let id = item["id"].as_u64().unwrap_or(0);
377 let title = s(&item["title"]);
378 let artist = s(&item["artists"][0]["name"]);
379 let track_count =
380 item["numberOfTracks"].as_u64().unwrap_or(0) as u32;
381 let release_date =
382 item["releaseDate"].as_str().map(str::to_string);
383 MonoEvent::SearchAlbum {
384 rank: rank as u32,
385 id,
386 title,
387 artist,
388 track_count,
389 release_date,
390 }
391 })
392 .collect())
393 }
394 SearchKind::Artists => {
395 let items = data["artists"]["items"]
397 .as_array()
398 .ok_or("missing data.artists.items")?;
399 Ok(items
400 .iter()
401 .enumerate()
402 .map(|(rank, item)| {
403 let id = item["id"].as_u64().unwrap_or(0);
404 let name = s(&item["name"]);
405 MonoEvent::SearchArtist {
406 rank: rank as u32,
407 id,
408 name,
409 }
410 })
411 .collect())
412 }
413 }
414 }
415
416 pub async fn lyrics(&self, id: u64) -> Result<Vec<MonoEvent>, String> {
419 let json = self.get(&format!("/lyrics/?id={id}")).await?;
420 let lobj = &json["lyrics"];
422
423 if let Some(lrc) = lobj["subtitles"].as_str() {
425 Ok(parse_lrc(lrc))
426 } else if let Some(plain) = lobj["lyrics"].as_str() {
427 Ok(plain
428 .lines()
429 .map(|line| MonoEvent::LyricLine {
430 timestamp_ms: None,
431 text: line.to_string(),
432 })
433 .collect())
434 } else {
435 Err("no lyrics found in response".to_string())
436 }
437 }
438
439 pub async fn recommendations(&self, id: u64) -> Result<Vec<MonoEvent>, String> {
442 let json = self.get(&format!("/recommendations/?id={id}")).await?;
443 let items = json["data"]["items"]
445 .as_array()
446 .ok_or("missing data.items")?;
447
448 Ok(items
449 .iter()
450 .enumerate()
451 .filter_map(|(rank, entry)| {
452 let track = &entry["track"];
454 let t = parse_track(track)?;
455 if let MonoEvent::Track {
456 id,
457 title,
458 artist,
459 duration_secs,
460 ..
461 } = t
462 {
463 Some(MonoEvent::Recommendation {
464 rank: rank as u32,
465 id,
466 title,
467 artist,
468 duration_secs,
469 })
470 } else {
471 None
472 }
473 })
474 .collect())
475 }
476
477 pub async fn cover(&self, id: u64, size: u32) -> Result<Vec<MonoEvent>, String> {
480 let json = self.get(&format!("/cover/?id={id}")).await?;
481 let covers = json["covers"]
483 .as_array()
484 .ok_or("missing covers array")?;
485
486 let first = covers.first().ok_or("empty covers array")?;
487
488 let sizes: &[u32] = if size == 0 { &[80, 640, 1280] } else { std::slice::from_ref(&size) };
489
490 let mut events = Vec::new();
491 for &s in sizes {
492 let key = s.to_string();
493 if let Some(url) = first[&key].as_str() {
494 events.push(MonoEvent::Cover {
495 url: url.to_string(),
496 size: s,
497 });
498 }
499 }
500
501 if events.is_empty() {
502 Err(format!("no cover URL found for size {size} (track {id})"))
503 } else {
504 Ok(events)
505 }
506 }
507}
508
509fn s(v: &Value) -> String {
513 v.as_str().unwrap_or("").to_string()
514}
515
516fn parse_track(v: &Value) -> Option<MonoEvent> {
519 let id = v["id"].as_u64()?;
520 let title = s(&v["title"]);
521 let version = v["version"].as_str().unwrap_or("");
522 let full_title = if version.is_empty() {
523 title
524 } else {
525 format!("{title} ({version})")
526 };
527
528 let artist = s(&v["artist"]["name"]);
529 let album = s(&v["album"]["title"]);
530 let album_id = v["album"]["id"].as_u64().unwrap_or(0);
531 let duration_secs = v["duration"].as_u64().unwrap_or(0);
532 let track_number = v["trackNumber"].as_u64().map(|n| n as u32);
533 let release_date = v["streamStartDate"]
534 .as_str()
535 .or_else(|| v["releaseDate"].as_str())
536 .map(str::to_string);
537 let audio_quality = v["audioQuality"].as_str().map(str::to_string);
538 let cover_id = v["album"]["cover"].as_str().map(str::to_string);
539
540 Some(MonoEvent::Track {
541 id,
542 title: full_title,
543 artist,
544 album,
545 album_id,
546 duration_secs,
547 track_number,
548 release_date,
549 audio_quality,
550 cover_id,
551 })
552}
553
554fn parse_lrc(lrc: &str) -> Vec<MonoEvent> {
559 lrc.lines()
560 .filter_map(|line| {
561 let line = line.trim();
563 if line.starts_with('[') {
564 let close = line.find(']')?;
565 let timestamp_str = &line[1..close];
566 let text = line[close + 1..].trim().to_string();
567
568 let ts_ms = parse_lrc_timestamp(timestamp_str);
570
571 Some(MonoEvent::LyricLine {
572 timestamp_ms: ts_ms,
573 text,
574 })
575 } else if !line.is_empty() {
576 Some(MonoEvent::LyricLine {
577 timestamp_ms: None,
578 text: line.to_string(),
579 })
580 } else {
581 None
582 }
583 })
584 .collect()
585}
586
587fn parse_lrc_timestamp(s: &str) -> Option<u64> {
589 let colon = s.find(':')?;
590 let mm: u64 = s[..colon].parse().ok()?;
591 let rest = &s[colon + 1..];
592 let (ss_str, cc_str) = rest.split_once('.').unwrap_or((rest, "0"));
593 let ss: u64 = ss_str.parse().ok()?;
594 let cc: u64 = cc_str.parse().ok().unwrap_or(0);
595 Some(mm * 60_000 + ss * 1_000 + cc * 10)
597}
598
599fn mime_to_ext(mime: &str) -> String {
601 match mime {
602 "audio/flac" => "flac",
603 "audio/mp4" | "audio/m4a" => "m4a",
604 "audio/mpeg" => "mp3",
605 "audio/ogg" => "ogg",
606 "audio/webm" => "webm",
607 _ => "audio",
608 }
609 .to_string()
610}
611
612fn url_encode(s: &str) -> String {
614 let mut out = String::with_capacity(s.len());
615 for b in s.bytes() {
616 match b {
617 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
618 out.push(b as char)
619 }
620 b' ' => out.push('+'),
621 _ => {
622 out.push('%');
623 out.push(char::from_digit((b >> 4) as u32, 16).unwrap());
624 out.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
625 }
626 }
627 }
628 out
629}