Skip to main content

koan_core/
lyrics.rs

1/// Lyrics fetch pipeline: embedded tags -> sidecar .lrc -> LRCLIB API -> DB cache.
2///
3/// Phase 1 implements LRCLIB + DB caching. Embedded and sidecar sources are
4/// stubbed and will be filled in Phase 2.
5use rusqlite::Connection;
6
7use crate::db::connection::DbError;
8use crate::db::queries::lyrics::{cache_lyrics, get_cached_lyrics, lyrics_fetched_at};
9use crate::remote::lrclib::{self, LrclibError};
10
11// ---------------------------------------------------------------------------
12// Public types
13// ---------------------------------------------------------------------------
14
15/// The resolved lyrics for a track.
16#[derive(Debug, Clone)]
17pub struct Lyrics {
18    /// Raw lyrics text. LRC format if `synced` is true, plain text otherwise.
19    pub content: String,
20    /// Whether `content` is in LRC (time-tagged) format.
21    pub synced: bool,
22    /// Which source provided the lyrics.
23    pub source: LyricsSource,
24}
25
26/// Which source provided the lyrics.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum LyricsSource {
29    /// Embedded in the audio file tags (e.g. USLT ID3, Vorbis LYRICS).
30    Embedded,
31    /// Sidecar `.lrc` file next to the audio file.
32    Sidecar,
33    /// LRCLIB community lyrics database.
34    Lrclib,
35    /// DB cache hit (originally from any of the above sources).
36    Cache,
37}
38
39impl LyricsSource {
40    fn as_str(&self) -> &'static str {
41        match self {
42            LyricsSource::Embedded => "embedded",
43            LyricsSource::Sidecar => "sidecar",
44            LyricsSource::Lrclib => "lrclib",
45            LyricsSource::Cache => "cache",
46        }
47    }
48}
49
50/// Errors that can occur during lyrics fetching.
51#[derive(Debug, thiserror::Error)]
52pub enum LyricsError {
53    #[error("database error: {0}")]
54    Db(#[from] DbError),
55    #[error("lrclib error: {0}")]
56    Lrclib(#[from] LrclibError),
57    #[error("lyrics not found")]
58    NotFound,
59}
60
61// ---------------------------------------------------------------------------
62// LRC parsing
63// ---------------------------------------------------------------------------
64
65/// A single time-tagged line from an LRC file.
66#[derive(Debug, Clone)]
67pub struct LrcLine {
68    /// Timestamp in seconds (e.g. 12.0 for `[00:12.00]`).
69    pub time_secs: f64,
70    /// The lyric text for this timestamp.
71    pub text: String,
72}
73
74/// Parse LRC-format text into a sorted list of [`LrcLine`]s.
75///
76/// Lines without a valid timestamp are silently skipped. The result is sorted
77/// by `time_secs` ascending so binary search works correctly.
78pub fn parse_lrc(content: &str) -> Vec<LrcLine> {
79    let mut lines: Vec<LrcLine> = content
80        .lines()
81        .filter_map(|line| {
82            // LRC timestamp: `[mm:ss.xx]` or `[mm:ss.xxx]`
83            let line = line.trim();
84            if !line.starts_with('[') {
85                return None;
86            }
87            let close = line.find(']')?;
88            let tag = &line[1..close];
89            let text = line[close + 1..].trim().to_string();
90
91            // Parse mm:ss.xx
92            let colon = tag.find(':')?;
93            let mins: f64 = tag[..colon].parse().ok()?;
94            let secs: f64 = tag[colon + 1..].parse().ok()?;
95            let time_secs = mins * 60.0 + secs;
96
97            Some(LrcLine { time_secs, text })
98        })
99        .collect();
100
101    lines.sort_by(|a, b| {
102        a.time_secs
103            .partial_cmp(&b.time_secs)
104            .unwrap_or(std::cmp::Ordering::Equal)
105    });
106    lines
107}
108
109/// Return the index of the current lyric line for the given playback position.
110///
111/// Returns `None` if the position is before the first timestamped line.
112/// Uses binary search for O(log n) lookup.
113pub fn current_line_index(lines: &[LrcLine], position_secs: f64) -> Option<usize> {
114    if lines.is_empty() {
115        return None;
116    }
117    // Find the last line whose timestamp <= position_secs.
118    match lines.binary_search_by(|l| {
119        l.time_secs
120            .partial_cmp(&position_secs)
121            .unwrap_or(std::cmp::Ordering::Less)
122    }) {
123        Ok(i) => Some(i),
124        Err(0) => None,        // before first line
125        Err(i) => Some(i - 1), // i is the insertion point; i-1 is the active line
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Fetch pipeline
131// ---------------------------------------------------------------------------
132
133/// How long a plain (unsynced) cached copy is trusted before LRCLIB is asked
134/// again. Synced lyrics are often added upstream after plain ones, and a plain
135/// copy held forever would never highlight.
136const PLAIN_RECHECK_SECS: i64 = 30 * 24 * 60 * 60;
137
138/// Fetch lyrics for a track using the priority chain:
139///
140/// 1. DB cache (instant, no network). A synced copy is final; a plain one is
141///    re-checked against LRCLIB once it is older than [`PLAIN_RECHECK_SECS`]
142/// 2. Embedded lyrics tag (stub — returns `None` in Phase 1)
143/// 3. Sidecar `.lrc` file (stub — returns `None` in Phase 1)
144/// 4. LRCLIB API
145///
146/// On a successful LRCLIB fetch the result is written to the DB cache so the
147/// next call is instant.
148pub fn fetch_lyrics(
149    conn: &Connection,
150    track_id: i64,
151    artist: &str,
152    title: &str,
153    album: &str,
154    duration_secs: u64,
155) -> Result<Lyrics, LyricsError> {
156    // 1. Check DB cache first.
157    let cached = get_cached_lyrics(conn, track_id)?;
158    if let Some((content, synced)) = &cached {
159        let stale = !synced
160            && lyrics_fetched_at(conn, track_id)?
161                .is_none_or(|at| now_secs() - at >= PLAIN_RECHECK_SECS);
162        if !stale {
163            return Ok(Lyrics {
164                content: content.clone(),
165                synced: *synced,
166                source: LyricsSource::Cache,
167            });
168        }
169    }
170    let fetched = fetch_from_lrclib(conn, track_id, artist, title, album, duration_secs);
171    match (fetched, cached) {
172        (Ok(lyrics), _) => Ok(lyrics),
173        // No synced copy upstream, or LRCLIB unreachable: keep the plain one,
174        // and restart its clock so the next play does not ask again.
175        (Err(_), Some((content, synced))) => {
176            cache_lyrics(
177                conn,
178                track_id,
179                LyricsSource::Lrclib.as_str(),
180                synced,
181                &content,
182            )?;
183            Ok(Lyrics {
184                content,
185                synced,
186                source: LyricsSource::Cache,
187            })
188        }
189        (Err(e), None) => Err(e),
190    }
191}
192
193fn now_secs() -> i64 {
194    std::time::SystemTime::now()
195        .duration_since(std::time::UNIX_EPOCH)
196        .map_or(0, |d| d.as_secs() as i64)
197}
198
199fn fetch_from_lrclib(
200    conn: &Connection,
201    track_id: i64,
202    artist: &str,
203    title: &str,
204    album: &str,
205    duration_secs: u64,
206) -> Result<Lyrics, LyricsError> {
207    // 2. Embedded lyrics (stub — Phase 2 will read via lofty ItemKey::Lyrics).
208    // 3. Sidecar .lrc (stub — Phase 2 will check `track_path.with_extension("lrc")`).
209
210    // 4. LRCLIB API.
211    let response =
212        lrclib::get_lyrics(artist, title, album, duration_secs).map_err(|e| match e {
213            LrclibError::NotFound => LyricsError::NotFound,
214            other => LyricsError::Lrclib(other),
215        })?;
216
217    // Prefer synced lyrics; fall back to plain.
218    let (content, synced) = if let Some(synced_lyrics) = response.synced_lyrics {
219        (synced_lyrics, true)
220    } else if let Some(plain_lyrics) = response.plain_lyrics {
221        (plain_lyrics, false)
222    } else {
223        return Err(LyricsError::NotFound);
224    };
225
226    // Cache the result.
227    cache_lyrics(
228        conn,
229        track_id,
230        LyricsSource::Lrclib.as_str(),
231        synced,
232        &content,
233    )?;
234
235    Ok(Lyrics {
236        content,
237        synced,
238        source: LyricsSource::Lrclib,
239    })
240}
241
242// ---------------------------------------------------------------------------
243// Tests
244// ---------------------------------------------------------------------------
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn db_with_track() -> (rusqlite::Connection, i64) {
251        let conn = rusqlite::Connection::open_in_memory().unwrap();
252        crate::db::schema::create_tables(&conn).unwrap();
253        let meta = crate::db::queries::sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
254        let id = crate::db::queries::tracks::upsert_track(&conn, &meta).unwrap();
255        (conn, id)
256    }
257
258    /// A recent plain copy is answered from the cache, with no network: this
259    /// test would fail against an unreachable LRCLIB if it asked.
260    #[test]
261    fn a_fresh_plain_copy_is_served_from_cache() {
262        let (conn, id) = db_with_track();
263        cache_lyrics(&conn, id, "lrclib", false, "plain").unwrap();
264        let got = fetch_lyrics(
265            &conn,
266            id,
267            "Aphex Twin",
268            "Windowlicker",
269            "Windowlicker EP",
270            0,
271        )
272        .unwrap();
273        assert!(!got.synced);
274        assert_eq!(got.content, "plain");
275    }
276
277    #[test]
278    fn a_synced_copy_is_final_however_old() {
279        let (conn, id) = db_with_track();
280        cache_lyrics(&conn, id, "lrclib", true, "[00:01.00]line").unwrap();
281        conn.execute("UPDATE lyrics_cache SET fetched_at = 0", [])
282            .unwrap();
283        let got = fetch_lyrics(
284            &conn,
285            id,
286            "Aphex Twin",
287            "Windowlicker",
288            "Windowlicker EP",
289            0,
290        )
291        .unwrap();
292        assert!(got.synced);
293    }
294
295    #[test]
296    fn test_parse_lrc_basic() {
297        let lrc = "[00:12.00]Hello world\n[00:17.20]Second line\n[01:05.50]Third line";
298        let lines = parse_lrc(lrc);
299        assert_eq!(lines.len(), 3);
300        assert!((lines[0].time_secs - 12.0).abs() < 0.01);
301        assert_eq!(lines[0].text, "Hello world");
302        assert!((lines[1].time_secs - 17.2).abs() < 0.01);
303        assert!((lines[2].time_secs - 65.5).abs() < 0.01);
304    }
305
306    #[test]
307    fn test_parse_lrc_skips_non_timestamp_lines() {
308        let lrc = "[ti:Song Title]\n[ar:Artist]\n[00:05.00]First lyric\n[00:10.00]Second lyric";
309        let lines = parse_lrc(lrc);
310        // [ti:...] and [ar:...] tags won't parse as mm:ss.xx (colon at wrong position / no dots)
311        // They might or might not parse depending on content; just verify the lyric lines are there.
312        assert!(lines.iter().any(|l| l.text == "First lyric"));
313        assert!(lines.iter().any(|l| l.text == "Second lyric"));
314    }
315
316    #[test]
317    fn test_current_line_index_empty() {
318        assert_eq!(current_line_index(&[], 5.0), None);
319    }
320
321    #[test]
322    fn test_current_line_index_before_first() {
323        let lines = parse_lrc("[00:10.00]First");
324        assert_eq!(current_line_index(&lines, 5.0), None);
325    }
326
327    #[test]
328    fn test_current_line_index_exact_match() {
329        let lines = parse_lrc("[00:10.00]First\n[00:20.00]Second");
330        assert_eq!(current_line_index(&lines, 10.0), Some(0));
331        assert_eq!(current_line_index(&lines, 20.0), Some(1));
332    }
333
334    #[test]
335    fn test_current_line_index_between_lines() {
336        let lines = parse_lrc("[00:10.00]First\n[00:20.00]Second\n[00:30.00]Third");
337        assert_eq!(current_line_index(&lines, 15.0), Some(0));
338        assert_eq!(current_line_index(&lines, 25.0), Some(1));
339        assert_eq!(current_line_index(&lines, 35.0), Some(2));
340    }
341}