1use 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#[derive(Debug, Clone)]
17pub struct Lyrics {
18 pub content: String,
20 pub synced: bool,
22 pub source: LyricsSource,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum LyricsSource {
29 Embedded,
31 Sidecar,
33 Lrclib,
35 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#[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#[derive(Debug, Clone)]
67pub struct LrcLine {
68 pub time_secs: f64,
70 pub text: String,
72}
73
74pub fn parse_lrc(content: &str) -> Vec<LrcLine> {
79 let mut lines: Vec<LrcLine> = content
80 .lines()
81 .filter_map(|line| {
82 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 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
109pub fn current_line_index(lines: &[LrcLine], position_secs: f64) -> Option<usize> {
114 if lines.is_empty() {
115 return None;
116 }
117 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, Err(i) => Some(i - 1), }
127}
128
129const PLAIN_RECHECK_SECS: i64 = 30 * 24 * 60 * 60;
137
138pub 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 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 (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 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 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_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#[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 #[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 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}