Skip to main content

plexus_mono/
client.rs

1//! HTTP client for the Monochrome / Hi-Fi Tidal proxy API
2//!
3//! All responses wrap a top-level `"version": "2.5"` field.
4//!
5//! Verified endpoint shapes (tested against https://api.monochrome.tf):
6//!
7//! GET /info/?id=<track_id>
8//!   → { version, data: { id, title, duration, trackNumber, audioQuality,
9//!                         artist: {id,name,picture}, album: {id,title,cover} } }
10//!
11//! GET /album/?id=<album_id>
12//!   → { version, data: { id, title, duration, numberOfTracks, releaseDate, cover,
13//!                         artist: {name}, items: [{ item: { track fields } }] } }
14//!
15//! GET /artist/?id=<artist_id>
16//!   → { version, artist: { id, name, picture, popularity },
17//!                cover: { id, name, "750": "https://..." } }
18//!   (note: top-level key is "artist", NOT "data")
19//!
20//! GET /search/?s=<q>&limit=N   (track search)
21//!   → { version, data: { totalNumberOfItems, items: [ track objects ] } }
22//!
23//! GET /search/?al=<q>&limit=N  (album search)
24//!   → { version, data: { albums: { items: [ album objects ] }, artists: { items: [] } } }
25//!
26//! GET /search/?a=<q>&limit=N   (artist search)
27//!   → { version, data: { artists: { items: [ artist objects ] } } }
28//!
29//! GET /lyrics/?id=<track_id>
30//!   → { version, lyrics: { lyrics: "plain text", subtitles: "lrc string" } }
31//!   subtitles format: "[MM:SS.cc] text\n[MM:SS.cc] text\n..."
32//!
33//! GET /recommendations/?id=<track_id>
34//!   → { version, data: { items: [{ track: { track fields } }] } }
35//!   (note: items wrap with "track" key)
36//!
37//! GET /cover/?id=<track_id>
38//!   → { version, covers: [{ id, name, "1280": "url", "640": "url", "80": "url" }] }
39//!   (note: size keys are strings: "1280", "640", "80")
40
41use crate::types::{MonoEvent, SearchKind};
42use base64::Engine as _;
43use serde_json::Value;
44
45/// HTTP client wrapping the Monochrome API.
46pub 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    // ── Stream manifest ──────────────────────────────────────────────────────
88
89    /// Resolve the stream manifest for a track.
90    ///
91    /// Returns a `MonoEvent::StreamManifest` with the direct pre-signed CDN URL.
92    /// The URL is short-lived (~60s) — use it immediately.
93    ///
94    /// `quality`: "LOSSLESS" (default), "HI_RES_LOSSLESS", "HIGH", "LOW"
95    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        // Manifest is a base64-encoded JSON blob
100        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    /// Download audio to a file, yielding progress events.
135    ///
136    /// Returns a stream of `DownloadProgress` followed by `DownloadComplete`.
137    /// `path` should be a file path (e.g. `/tmp/track.flac`).
138    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        // Resolve the manifest URL
148        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        // GET the audio stream
157        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        // Emit first progress event
177        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; // report every 256 KB
185        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    // ── Track ────────────────────────────────────────────────────────────────
221
222    pub async fn track_info(&self, id: u64) -> Result<MonoEvent, String> {
223        let json = self.get(&format!("/info/?id={id}")).await?;
224        // Response: { version, data: { track fields } }
225        let data = &json["data"];
226        parse_track(data).ok_or_else(|| format!("could not parse track {id}"))
227    }
228
229    // ── Album ────────────────────────────────────────────────────────────────
230
231    pub async fn album(&self, id: u64) -> Result<(MonoEvent, Vec<MonoEvent>), String> {
232        let json = self.get(&format!("/album/?id={id}")).await?;
233        // Response: { version, data: { album fields, items: [{item: track}] } }
234        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                        // Each entry is { "item": { track fields } }
261                        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    // ── Artist ───────────────────────────────────────────────────────────────
292
293    pub async fn artist(&self, id: u64) -> Result<MonoEvent, String> {
294        let json = self.get(&format!("/artist/?id={id}")).await?;
295        // Response: { version, artist: { id, name, picture }, cover: { "750": url } }
296        // NOTE: top-level key is "artist", not "data"
297        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        // Cover comes as a separate top-level object: { "750": "https://..." }
303        // We capture the 750px URL as the cover
304        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    // ── Search ───────────────────────────────────────────────────────────────
315
316    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                // data: { items: [ direct track objects ] }
336                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                // data: { albums: { items: [ album objects ] }, artists: { items: [] } }
369                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                // data: { artists: { items: [ artist objects ] } }
396                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    // ── Lyrics ───────────────────────────────────────────────────────────────
417
418    pub async fn lyrics(&self, id: u64) -> Result<Vec<MonoEvent>, String> {
419        let json = self.get(&format!("/lyrics/?id={id}")).await?;
420        // Response: { version, lyrics: { lyrics: "plain", subtitles: "lrc" } }
421        let lobj = &json["lyrics"];
422
423        // Prefer synced subtitles (LRC format) over plain lyrics
424        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    // ── Recommendations ──────────────────────────────────────────────────────
440
441    pub async fn recommendations(&self, id: u64) -> Result<Vec<MonoEvent>, String> {
442        let json = self.get(&format!("/recommendations/?id={id}")).await?;
443        // Response: { version, data: { items: [{ "track": { track fields } }] } }
444        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                // Each entry is { "track": { track fields } }
453                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    // ── Cover ────────────────────────────────────────────────────────────────
478
479    pub async fn cover(&self, id: u64, size: u32) -> Result<Vec<MonoEvent>, String> {
480        let json = self.get(&format!("/cover/?id={id}")).await?;
481        // Response: { version, covers: [{ "1280": url, "640": url, "80": url }] }
482        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
509// ── Helpers ──────────────────────────────────────────────────────────────────
510
511/// Extract string value from JSON, defaulting to empty string.
512fn s(v: &Value) -> String {
513    v.as_str().unwrap_or("").to_string()
514}
515
516/// Parse a track JSON object into a `MonoEvent::Track`.
517/// Returns None if `id` is missing (skips garbage entries).
518fn 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
554/// Parse LRC (lyric) format into `MonoEvent::LyricLine` events.
555///
556/// LRC format: `[MM:SS.cc] lyric text`
557/// Example: `[01:00.66] But I'm a creep, I'm a weirdo`
558fn parse_lrc(lrc: &str) -> Vec<MonoEvent> {
559    lrc.lines()
560        .filter_map(|line| {
561            // Strip the [MM:SS.cc] prefix
562            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                // Parse [MM:SS.cc]
569                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
587/// Parse "MM:SS.cc" → milliseconds. Returns None on failure.
588fn 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    // cc is centiseconds (2 digits = 1/100 sec)
596    Some(mm * 60_000 + ss * 1_000 + cc * 10)
597}
598
599/// Map MIME type to a file extension.
600fn 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
612/// Simple URL percent-encoding.
613fn 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}