1use rusqlite::Connection;
6
7use crate::db::connection::DbError;
8use crate::db::queries::lyrics::{cache_lyrics, get_cached_lyrics};
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
129pub 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 if let Some((content, synced)) = get_cached_lyrics(conn, track_id)? {
152 return Ok(Lyrics {
153 content,
154 synced,
155 source: LyricsSource::Cache,
156 });
157 }
158
159 let response =
164 lrclib::get_lyrics(artist, title, album, duration_secs).map_err(|e| match e {
165 LrclibError::NotFound => LyricsError::NotFound,
166 other => LyricsError::Lrclib(other),
167 })?;
168
169 let (content, synced) = if let Some(synced_lyrics) = response.synced_lyrics {
171 (synced_lyrics, true)
172 } else if let Some(plain_lyrics) = response.plain_lyrics {
173 (plain_lyrics, false)
174 } else {
175 return Err(LyricsError::NotFound);
176 };
177
178 cache_lyrics(
180 conn,
181 track_id,
182 LyricsSource::Lrclib.as_str(),
183 synced,
184 &content,
185 )?;
186
187 Ok(Lyrics {
188 content,
189 synced,
190 source: LyricsSource::Lrclib,
191 })
192}
193
194#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn test_parse_lrc_basic() {
204 let lrc = "[00:12.00]Hello world\n[00:17.20]Second line\n[01:05.50]Third line";
205 let lines = parse_lrc(lrc);
206 assert_eq!(lines.len(), 3);
207 assert!((lines[0].time_secs - 12.0).abs() < 0.01);
208 assert_eq!(lines[0].text, "Hello world");
209 assert!((lines[1].time_secs - 17.2).abs() < 0.01);
210 assert!((lines[2].time_secs - 65.5).abs() < 0.01);
211 }
212
213 #[test]
214 fn test_parse_lrc_skips_non_timestamp_lines() {
215 let lrc = "[ti:Song Title]\n[ar:Artist]\n[00:05.00]First lyric\n[00:10.00]Second lyric";
216 let lines = parse_lrc(lrc);
217 assert!(lines.iter().any(|l| l.text == "First lyric"));
220 assert!(lines.iter().any(|l| l.text == "Second lyric"));
221 }
222
223 #[test]
224 fn test_current_line_index_empty() {
225 assert_eq!(current_line_index(&[], 5.0), None);
226 }
227
228 #[test]
229 fn test_current_line_index_before_first() {
230 let lines = parse_lrc("[00:10.00]First");
231 assert_eq!(current_line_index(&lines, 5.0), None);
232 }
233
234 #[test]
235 fn test_current_line_index_exact_match() {
236 let lines = parse_lrc("[00:10.00]First\n[00:20.00]Second");
237 assert_eq!(current_line_index(&lines, 10.0), Some(0));
238 assert_eq!(current_line_index(&lines, 20.0), Some(1));
239 }
240
241 #[test]
242 fn test_current_line_index_between_lines() {
243 let lines = parse_lrc("[00:10.00]First\n[00:20.00]Second\n[00:30.00]Third");
244 assert_eq!(current_line_index(&lines, 15.0), Some(0));
245 assert_eq!(current_line_index(&lines, 25.0), Some(1));
246 assert_eq!(current_line_index(&lines, 35.0), Some(2));
247 }
248}