1use 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#[derive(Debug, Clone)]
15pub struct Lyrics {
16 pub content: String,
18 pub synced: bool,
20 pub source: LyricsSource,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum LyricsSource {
27 Embedded,
29 Sidecar,
31 Lrclib,
33 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#[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#[derive(Debug, Clone)]
65pub struct LrcLine {
66 pub time_secs: f64,
68 pub text: String,
70}
71
72pub fn parse_lrc(content: &str) -> Vec<LrcLine> {
77 let mut lines: Vec<LrcLine> = content
78 .lines()
79 .filter_map(|line| {
80 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 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
107pub fn current_line_index(lines: &[LrcLine], position_secs: f64) -> Option<usize> {
112 if lines.is_empty() {
113 return None;
114 }
115 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, Err(i) => Some(i - 1), }
125}
126
127const PLAIN_RECHECK_SECS: i64 = 30 * 24 * 60 * 60;
135
136pub 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 match look_up_cached(conn, track_id)? {
151 CacheLookup::Fresh(lyrics) => Ok(lyrics),
152 CacheLookup::Stale(cached) => {
153 let fetched = fetch_from_lrclib(artist, title, album, duration_secs);
154 settle(conn, track_id, fetched, cached)
155 }
156 }
157}
158
159pub enum CacheLookup {
166 Fresh(Lyrics),
168 Stale(Option<(String, bool)>),
171}
172
173pub fn look_up_cached(conn: &Connection, track_id: i64) -> Result<CacheLookup, LyricsError> {
174 let cached = get_cached_lyrics(conn, track_id)?;
175 if let Some((content, synced)) = &cached {
176 let stale = !synced
177 && lyrics_fetched_at(conn, track_id)?
178 .is_none_or(|at| now_secs() - at >= PLAIN_RECHECK_SECS);
179 if !stale {
180 return Ok(CacheLookup::Fresh(Lyrics {
181 content: content.clone(),
182 synced: *synced,
183 source: LyricsSource::Cache,
184 }));
185 }
186 }
187 Ok(CacheLookup::Stale(cached))
188}
189
190pub fn settle(
192 conn: &Connection,
193 track_id: i64,
194 fetched: Result<Lyrics, LyricsError>,
195 cached: Option<(String, bool)>,
196) -> Result<Lyrics, LyricsError> {
197 match (fetched, cached) {
198 (Ok(lyrics), _) => {
199 cache_lyrics(
200 conn,
201 track_id,
202 LyricsSource::Lrclib.as_str(),
203 lyrics.synced,
204 &lyrics.content,
205 )?;
206 Ok(lyrics)
207 }
208 (Err(_), Some((content, synced))) => {
211 cache_lyrics(
212 conn,
213 track_id,
214 LyricsSource::Lrclib.as_str(),
215 synced,
216 &content,
217 )?;
218 Ok(Lyrics {
219 content,
220 synced,
221 source: LyricsSource::Cache,
222 })
223 }
224 (Err(e), None) => Err(e),
225 }
226}
227
228fn now_secs() -> i64 {
229 std::time::SystemTime::now()
230 .duration_since(std::time::UNIX_EPOCH)
231 .map_or(0, |d| d.as_secs() as i64)
232}
233
234pub fn fetch_from_lrclib(
236 artist: &str,
237 title: &str,
238 album: &str,
239 duration_secs: u64,
240) -> Result<Lyrics, LyricsError> {
241 let response =
242 lrclib::get_lyrics(artist, title, album, duration_secs).map_err(|e| match e {
243 LrclibError::NotFound => LyricsError::NotFound,
244 other => LyricsError::Lrclib(other),
245 })?;
246
247 let (content, synced) = if let Some(synced_lyrics) = response.synced_lyrics {
248 (synced_lyrics, true)
249 } else if let Some(plain_lyrics) = response.plain_lyrics {
250 (plain_lyrics, false)
251 } else {
252 return Err(LyricsError::NotFound);
253 };
254
255 Ok(Lyrics {
256 content,
257 synced,
258 source: LyricsSource::Lrclib,
259 })
260}
261
262#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn db_with_track() -> (rusqlite::Connection, i64) {
271 let conn = rusqlite::Connection::open_in_memory().unwrap();
272 crate::db::schema::create_tables(&conn).unwrap();
273 let meta = crate::db::queries::sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
274 let id = crate::db::queries::tracks::upsert_track(&conn, &meta).unwrap();
275 (conn, id)
276 }
277
278 #[test]
281 fn a_fresh_plain_copy_is_served_from_cache() {
282 let (conn, id) = db_with_track();
283 cache_lyrics(&conn, id, "lrclib", false, "plain").unwrap();
284 let got = fetch_lyrics(
285 &conn,
286 id,
287 "Aphex Twin",
288 "Windowlicker",
289 "Windowlicker EP",
290 0,
291 )
292 .unwrap();
293 assert!(!got.synced);
294 assert_eq!(got.content, "plain");
295 }
296
297 #[test]
298 fn a_synced_copy_is_final_however_old() {
299 let (conn, id) = db_with_track();
300 cache_lyrics(&conn, id, "lrclib", true, "[00:01.00]line").unwrap();
301 conn.execute("UPDATE lyrics_cache SET fetched_at = 0", [])
302 .unwrap();
303 let got = fetch_lyrics(
304 &conn,
305 id,
306 "Aphex Twin",
307 "Windowlicker",
308 "Windowlicker EP",
309 0,
310 )
311 .unwrap();
312 assert!(got.synced);
313 }
314
315 #[test]
316 fn test_parse_lrc_basic() {
317 let lrc = "[00:12.00]Hello world\n[00:17.20]Second line\n[01:05.50]Third line";
318 let lines = parse_lrc(lrc);
319 assert_eq!(lines.len(), 3);
320 assert!((lines[0].time_secs - 12.0).abs() < 0.01);
321 assert_eq!(lines[0].text, "Hello world");
322 assert!((lines[1].time_secs - 17.2).abs() < 0.01);
323 assert!((lines[2].time_secs - 65.5).abs() < 0.01);
324 }
325
326 #[test]
327 fn test_parse_lrc_skips_non_timestamp_lines() {
328 let lrc = "[ti:Song Title]\n[ar:Artist]\n[00:05.00]First lyric\n[00:10.00]Second lyric";
329 let lines = parse_lrc(lrc);
330 assert!(lines.iter().any(|l| l.text == "First lyric"));
333 assert!(lines.iter().any(|l| l.text == "Second lyric"));
334 }
335
336 #[test]
337 fn test_current_line_index_empty() {
338 assert_eq!(current_line_index(&[], 5.0), None);
339 }
340
341 #[test]
342 fn test_current_line_index_before_first() {
343 let lines = parse_lrc("[00:10.00]First");
344 assert_eq!(current_line_index(&lines, 5.0), None);
345 }
346
347 #[test]
348 fn test_current_line_index_exact_match() {
349 let lines = parse_lrc("[00:10.00]First\n[00:20.00]Second");
350 assert_eq!(current_line_index(&lines, 10.0), Some(0));
351 assert_eq!(current_line_index(&lines, 20.0), Some(1));
352 }
353
354 #[test]
355 fn test_current_line_index_between_lines() {
356 let lines = parse_lrc("[00:10.00]First\n[00:20.00]Second\n[00:30.00]Third");
357 assert_eq!(current_line_index(&lines, 15.0), Some(0));
358 assert_eq!(current_line_index(&lines, 25.0), Some(1));
359 assert_eq!(current_line_index(&lines, 35.0), Some(2));
360 }
361}