Skip to main content

koan_core/remote/
lrclib.rs

1use serde::Deserialize;
2use thiserror::Error;
3
4const USER_AGENT: &str = "koan-music/0.3.0";
5
6#[derive(Debug, Error)]
7pub enum LrclibError {
8    #[error("http error: {0}")]
9    Http(#[from] reqwest::Error),
10    #[error("not found")]
11    NotFound,
12}
13
14#[derive(Debug, Clone, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct LrclibResponse {
17    pub synced_lyrics: Option<String>,
18    pub plain_lyrics: Option<String>,
19}
20
21/// Fetch lyrics from LRCLIB for a given track.
22///
23/// Tries the exact `/api/get` endpoint first (requires artist, title, album, duration).
24/// If that returns 404, falls back to `/api/search` which does fuzzy matching
25/// on artist + title only.
26pub fn get_lyrics(
27    artist: &str,
28    title: &str,
29    album: &str,
30    duration_secs: u64,
31) -> Result<LrclibResponse, LrclibError> {
32    let client = reqwest::blocking::Client::builder()
33        .user_agent(USER_AGENT)
34        .build()?;
35
36    // Try exact match first.
37    let resp = client
38        .get("https://lrclib.net/api/get")
39        .query(&[
40            ("artist_name", artist),
41            ("track_name", title),
42            ("album_name", album),
43        ])
44        .query(&[("duration", &duration_secs.to_string())])
45        .send()?;
46
47    if resp.status() != reqwest::StatusCode::NOT_FOUND {
48        let body: LrclibResponse = resp.error_for_status()?.json()?;
49        return Ok(body);
50    }
51
52    // Fallback: fuzzy search by artist + title.
53    let query = format!("{} {}", artist, title);
54    let resp = client
55        .get("https://lrclib.net/api/search")
56        .query(&[("q", query.as_str())])
57        .send()?;
58
59    let results: Vec<LrclibResponse> = resp.error_for_status()?.json()?;
60    results.into_iter().next().ok_or(LrclibError::NotFound)
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn test_deserialize_lrclib_response() {
69        let json = r#"{
70            "id": 123,
71            "trackName": "Test",
72            "artistName": "Artist",
73            "albumName": "Album",
74            "duration": 240,
75            "instrumental": false,
76            "plainLyrics": "Hello world\nSecond line",
77            "syncedLyrics": "[00:12.00]Hello world\n[00:17.20]Second line"
78        }"#;
79        let resp: LrclibResponse = serde_json::from_str(json).unwrap();
80        assert_eq!(
81            resp.plain_lyrics.as_deref(),
82            Some("Hello world\nSecond line")
83        );
84        assert!(resp.synced_lyrics.unwrap().contains("[00:12.00]"));
85    }
86
87    #[test]
88    fn test_deserialize_lrclib_response_null_lyrics() {
89        let json = r#"{
90            "id": 456,
91            "trackName": "Instrumental",
92            "artistName": "Artist",
93            "albumName": "Album",
94            "duration": 180,
95            "instrumental": true,
96            "plainLyrics": null,
97            "syncedLyrics": null
98        }"#;
99        let resp: LrclibResponse = serde_json::from_str(json).unwrap();
100        assert!(resp.plain_lyrics.is_none());
101        assert!(resp.synced_lyrics.is_none());
102    }
103}