use rusqlite::Connection;
use crate::db::connection::DbError;
use crate::db::queries::lyrics::{cache_lyrics, get_cached_lyrics, lyrics_fetched_at};
use crate::remote::lrclib::{self, LrclibError};
#[derive(Debug, Clone)]
pub struct Lyrics {
pub content: String,
pub synced: bool,
pub source: LyricsSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LyricsSource {
Embedded,
Sidecar,
Lrclib,
Cache,
}
impl LyricsSource {
fn as_str(&self) -> &'static str {
match self {
LyricsSource::Embedded => "embedded",
LyricsSource::Sidecar => "sidecar",
LyricsSource::Lrclib => "lrclib",
LyricsSource::Cache => "cache",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum LyricsError {
#[error("database error: {0}")]
Db(#[from] DbError),
#[error("lrclib error: {0}")]
Lrclib(#[from] LrclibError),
#[error("lyrics not found")]
NotFound,
}
#[derive(Debug, Clone)]
pub struct LrcLine {
pub time_secs: f64,
pub text: String,
}
pub fn parse_lrc(content: &str) -> Vec<LrcLine> {
let mut lines: Vec<LrcLine> = content
.lines()
.filter_map(|line| {
let line = line.trim();
if !line.starts_with('[') {
return None;
}
let close = line.find(']')?;
let tag = &line[1..close];
let text = line[close + 1..].trim().to_string();
let colon = tag.find(':')?;
let mins: f64 = tag[..colon].parse().ok()?;
let secs: f64 = tag[colon + 1..].parse().ok()?;
let time_secs = mins * 60.0 + secs;
Some(LrcLine { time_secs, text })
})
.collect();
lines.sort_by(|a, b| {
a.time_secs
.partial_cmp(&b.time_secs)
.unwrap_or(std::cmp::Ordering::Equal)
});
lines
}
pub fn current_line_index(lines: &[LrcLine], position_secs: f64) -> Option<usize> {
if lines.is_empty() {
return None;
}
match lines.binary_search_by(|l| {
l.time_secs
.partial_cmp(&position_secs)
.unwrap_or(std::cmp::Ordering::Less)
}) {
Ok(i) => Some(i),
Err(0) => None, Err(i) => Some(i - 1), }
}
const PLAIN_RECHECK_SECS: i64 = 30 * 24 * 60 * 60;
pub fn fetch_lyrics(
conn: &Connection,
track_id: i64,
artist: &str,
title: &str,
album: &str,
duration_secs: u64,
) -> Result<Lyrics, LyricsError> {
let cached = get_cached_lyrics(conn, track_id)?;
if let Some((content, synced)) = &cached {
let stale = !synced
&& lyrics_fetched_at(conn, track_id)?
.is_none_or(|at| now_secs() - at >= PLAIN_RECHECK_SECS);
if !stale {
return Ok(Lyrics {
content: content.clone(),
synced: *synced,
source: LyricsSource::Cache,
});
}
}
let fetched = fetch_from_lrclib(conn, track_id, artist, title, album, duration_secs);
match (fetched, cached) {
(Ok(lyrics), _) => Ok(lyrics),
(Err(_), Some((content, synced))) => {
cache_lyrics(
conn,
track_id,
LyricsSource::Lrclib.as_str(),
synced,
&content,
)?;
Ok(Lyrics {
content,
synced,
source: LyricsSource::Cache,
})
}
(Err(e), None) => Err(e),
}
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
fn fetch_from_lrclib(
conn: &Connection,
track_id: i64,
artist: &str,
title: &str,
album: &str,
duration_secs: u64,
) -> Result<Lyrics, LyricsError> {
let response =
lrclib::get_lyrics(artist, title, album, duration_secs).map_err(|e| match e {
LrclibError::NotFound => LyricsError::NotFound,
other => LyricsError::Lrclib(other),
})?;
let (content, synced) = if let Some(synced_lyrics) = response.synced_lyrics {
(synced_lyrics, true)
} else if let Some(plain_lyrics) = response.plain_lyrics {
(plain_lyrics, false)
} else {
return Err(LyricsError::NotFound);
};
cache_lyrics(
conn,
track_id,
LyricsSource::Lrclib.as_str(),
synced,
&content,
)?;
Ok(Lyrics {
content,
synced,
source: LyricsSource::Lrclib,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn db_with_track() -> (rusqlite::Connection, i64) {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::schema::create_tables(&conn).unwrap();
let meta = crate::db::queries::sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
let id = crate::db::queries::tracks::upsert_track(&conn, &meta).unwrap();
(conn, id)
}
#[test]
fn a_fresh_plain_copy_is_served_from_cache() {
let (conn, id) = db_with_track();
cache_lyrics(&conn, id, "lrclib", false, "plain").unwrap();
let got = fetch_lyrics(
&conn,
id,
"Aphex Twin",
"Windowlicker",
"Windowlicker EP",
0,
)
.unwrap();
assert!(!got.synced);
assert_eq!(got.content, "plain");
}
#[test]
fn a_synced_copy_is_final_however_old() {
let (conn, id) = db_with_track();
cache_lyrics(&conn, id, "lrclib", true, "[00:01.00]line").unwrap();
conn.execute("UPDATE lyrics_cache SET fetched_at = 0", [])
.unwrap();
let got = fetch_lyrics(
&conn,
id,
"Aphex Twin",
"Windowlicker",
"Windowlicker EP",
0,
)
.unwrap();
assert!(got.synced);
}
#[test]
fn test_parse_lrc_basic() {
let lrc = "[00:12.00]Hello world\n[00:17.20]Second line\n[01:05.50]Third line";
let lines = parse_lrc(lrc);
assert_eq!(lines.len(), 3);
assert!((lines[0].time_secs - 12.0).abs() < 0.01);
assert_eq!(lines[0].text, "Hello world");
assert!((lines[1].time_secs - 17.2).abs() < 0.01);
assert!((lines[2].time_secs - 65.5).abs() < 0.01);
}
#[test]
fn test_parse_lrc_skips_non_timestamp_lines() {
let lrc = "[ti:Song Title]\n[ar:Artist]\n[00:05.00]First lyric\n[00:10.00]Second lyric";
let lines = parse_lrc(lrc);
assert!(lines.iter().any(|l| l.text == "First lyric"));
assert!(lines.iter().any(|l| l.text == "Second lyric"));
}
#[test]
fn test_current_line_index_empty() {
assert_eq!(current_line_index(&[], 5.0), None);
}
#[test]
fn test_current_line_index_before_first() {
let lines = parse_lrc("[00:10.00]First");
assert_eq!(current_line_index(&lines, 5.0), None);
}
#[test]
fn test_current_line_index_exact_match() {
let lines = parse_lrc("[00:10.00]First\n[00:20.00]Second");
assert_eq!(current_line_index(&lines, 10.0), Some(0));
assert_eq!(current_line_index(&lines, 20.0), Some(1));
}
#[test]
fn test_current_line_index_between_lines() {
let lines = parse_lrc("[00:10.00]First\n[00:20.00]Second\n[00:30.00]Third");
assert_eq!(current_line_index(&lines, 15.0), Some(0));
assert_eq!(current_line_index(&lines, 25.0), Some(1));
assert_eq!(current_line_index(&lines, 35.0), Some(2));
}
}