Skip to main content

koan_core/
lyrics.rs

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