1use percent_encoding::NON_ALPHANUMERIC;
2use serde::Deserialize;
3use std::collections::{HashSet, hash_map::DefaultHasher};
4use std::hash::{Hash, Hasher};
5use std::time::{Duration, Instant};
6
7mod cache;
8mod local;
9mod lrc;
10mod model;
11mod request;
12mod server;
13
14use cache::{
15 LyricsInflightGuard, load_persisted_lyrics, lyrics_cache, store_lyrics, try_begin_lyrics_fetch,
16};
17use local::fetch_local_lrc;
18use lrc::{extract_line_timestamps, parse_enhanced_words, parse_lrc};
19pub use model::{LyricChunk, LyricLine, Lyrics};
20pub use request::{LyricsRequest, LyricsServerAuth};
21use server::{fetch_jellyfin_lyrics, fetch_subsonic_lyrics};
22
23const LRCLIB_TIMEOUT: Duration = Duration::from_secs(5);
24const SERVER_LYRICS_TIMEOUT: Duration = Duration::from_secs(5);
25const LYRICS_INFLIGHT_POLL_INTERVAL: Duration = Duration::from_millis(50);
26const LYRICS_INFLIGHT_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
27
28macro_rules! lyrics_debug {
29 ($($arg:tt)*) => {
30 if $crate::lyrics::lyrics_terminal_debug_enabled() {
31 tracing::debug!("[lyrics] {}", format_args!($($arg)*));
32 }
33 };
34}
35
36mod musixmatch;
37mod paxsenix;
38
39use musixmatch::fetch_from_musixmatch_enhanced;
40use paxsenix::{fetch_from_paxsenix_apple_music, fetch_from_paxsenix_youtube};
41
42#[derive(Debug, Deserialize)]
43struct LrcLibResponse {
44 #[serde(rename = "syncedLyrics")]
45 synced_lyrics: Option<String>,
46 #[serde(rename = "plainLyrics")]
47 plain_lyrics: Option<String>,
48}
49
50#[derive(Debug, Deserialize)]
53struct ItunesSearchResponse {
54 #[serde(default)]
55 results: Vec<ItunesSong>,
56}
57
58#[derive(Debug, Deserialize)]
59#[serde(rename_all = "camelCase")]
60struct ItunesSong {
61 track_id: u64,
62 track_name: String,
63 artist_name: String,
64 #[serde(default)]
65 track_time_millis: Option<u64>,
66}
67
68#[derive(Debug, Deserialize)]
69struct PaxsenixAppleLyricsResponse {
70 #[serde(default)]
71 content: Vec<PaxsenixAppleLyricLine>,
72 #[serde(default)]
73 lrc: Option<String>,
74 #[serde(default)]
75 plain: Option<String>,
76}
77
78#[derive(Debug, Deserialize)]
79struct PaxsenixAppleLyricLine {
80 #[serde(default)]
81 text: Vec<PaxsenixAppleLyricPart>,
82 #[serde(default, rename = "backgroundText")]
83 background_text: Vec<PaxsenixAppleLyricPart>,
84 timestamp: u64,
85 #[serde(default)]
86 endtime: Option<u64>,
87 #[serde(default)]
88 background: bool,
89 #[serde(default, rename = "oppositeTurn")]
90 opposite_turn: bool,
91}
92
93#[derive(Debug, Deserialize)]
94struct PaxsenixAppleLyricPart {
95 text: String,
96 #[serde(default)]
97 timestamp: Option<u64>,
98 #[serde(default)]
99 part: bool,
100}
101
102#[derive(Debug, Deserialize)]
105#[serde(rename_all = "camelCase")]
106struct PaxsenixYoutubeSearchResult {
107 video_id: String,
108 title: String,
109 author: String,
110 duration: String,
111}
112
113#[tracing::instrument(name = "lyrics.fetch", skip_all, fields(artist = %request.artist, title = %request.title))]
130pub async fn fetch_lyrics_for_request(request: &LyricsRequest) -> Option<Lyrics> {
131 fetch_lyrics_with_progress(request, true, |_| {}).await
132}
133
134pub async fn fetch_lyrics_progressive_for_request<F>(
135 request: &LyricsRequest,
136 on_progress: F,
137) -> Option<Lyrics>
138where
139 F: FnMut(Lyrics),
140{
141 fetch_lyrics_with_progress(request, true, on_progress).await
142}
143
144#[deprecated(note = "use LyricsRequest with fetch_lyrics_for_request")]
145#[allow(clippy::too_many_arguments)]
146pub async fn fetch_lyrics(
147 artist: &str,
148 title: &str,
149 album: &str,
150 duration: u64,
151 track_path: &str,
152 server_url: Option<&str>,
153 server_token: Option<&str>,
154 server_user_id: Option<&str>,
155 prefer_local: bool,
156 enable_musixmatch: bool,
157) -> Option<Lyrics> {
158 let request = LyricsRequest::new(artist, title, album, duration, track_path)
159 .with_server(server_url, server_token, server_user_id)
160 .prefer_local(prefer_local)
161 .enable_musixmatch(enable_musixmatch);
162 fetch_lyrics_for_request(&request).await
163}
164
165#[deprecated(note = "use LyricsRequest with fetch_lyrics_progressive_for_request")]
166#[allow(clippy::too_many_arguments)]
167pub async fn fetch_lyrics_progressive<F>(
168 artist: &str,
169 title: &str,
170 album: &str,
171 duration: u64,
172 track_path: &str,
173 server_url: Option<&str>,
174 server_token: Option<&str>,
175 server_user_id: Option<&str>,
176 prefer_local: bool,
177 enable_musixmatch: bool,
178 on_progress: F,
179) -> Option<Lyrics>
180where
181 F: FnMut(Lyrics),
182{
183 let request = LyricsRequest::new(artist, title, album, duration, track_path)
184 .with_server(server_url, server_token, server_user_id)
185 .prefer_local(prefer_local)
186 .enable_musixmatch(enable_musixmatch);
187 fetch_lyrics_progressive_for_request(&request, on_progress).await
188}
189
190async fn fetch_lyrics_with_progress<F>(
191 request: &LyricsRequest,
192 allow_lrclib: bool,
193 mut on_progress: F,
194) -> Option<Lyrics>
195where
196 F: FnMut(Lyrics),
197{
198 let cache_key = request.cache_key();
199 let artist = request.artist.as_str();
200 let title = request.title.as_str();
201 let album = request.album.as_str();
202 let duration = request.duration;
203 let track_path = request.track_path.as_str();
204 let server_url = request.server.as_ref().map(|server| server.url.as_str());
205 let server_token = request
206 .server
207 .as_ref()
208 .and_then(|server| server.token.as_deref());
209 let server_user_id = request
210 .server
211 .as_ref()
212 .and_then(|server| server.user_id.as_deref());
213 let prefer_local = request.prefer_local;
214 let enable_musixmatch = request.enable_musixmatch;
215 let cache_key_hash = log_lyrics_key_hash(&cache_key);
216 let total_start = Instant::now();
217 lyrics_debug!(
218 "fetch start key_hash={} artist={:?} title={:?} duration={} prefer_local={}",
219 cache_key_hash,
220 request.artist,
221 request.title,
222 request.duration,
223 request.prefer_local
224 );
225 if let Some(cached) = lyrics_cache()
226 .lock()
227 .ok()
228 .and_then(|mut cache| cache.get_cloned(&cache_key))
229 {
230 tracing::info!(
231 target: "kopuz::lyrics",
232 "cache hit key_hash={} kind={}",
233 log_lyrics_key_hash(&cache_key),
234 lyrics_kind(cached.as_ref())
235 );
236 lyrics_debug!(
237 "cache hit key_hash={} kind={}",
238 cache_key_hash,
239 lyrics_kind(cached.as_ref())
240 );
241 return cached;
242 }
243
244 if let Some(persisted) = load_persisted_lyrics(&cache_key).await {
247 lyrics_debug!(
248 "db hit key_hash={} kind={}",
249 cache_key_hash,
250 lyrics_kind(persisted.as_ref())
251 );
252 if let Ok(mut cache) = lyrics_cache().lock() {
253 cache.put(cache_key.clone(), persisted.clone());
254 }
255 return persisted;
256 }
257
258 let _inflight_guard = if try_begin_lyrics_fetch(&cache_key) {
259 lyrics_debug!("inflight acquired key_hash={}", cache_key_hash);
260 Some(LyricsInflightGuard {
261 key: cache_key.clone(),
262 })
263 } else {
264 lyrics_debug!(
265 "inflight wait key_hash={} timeout_ms={}",
266 cache_key_hash,
267 LYRICS_INFLIGHT_WAIT_TIMEOUT.as_millis()
268 );
269 let wait_start = Instant::now();
270 while wait_start.elapsed() < LYRICS_INFLIGHT_WAIT_TIMEOUT {
271 crate::sleep(LYRICS_INFLIGHT_POLL_INTERVAL).await;
272 if let Some(cached) = lyrics_cache()
273 .lock()
274 .ok()
275 .and_then(|mut cache| cache.get_cloned(&cache_key))
276 {
277 lyrics_debug!(
278 "inflight resolved from cache key_hash={} elapsed_ms={} kind={}",
279 cache_key_hash,
280 wait_start.elapsed().as_millis(),
281 lyrics_kind(cached.as_ref())
282 );
283 return cached;
284 }
285 }
286
287 if try_begin_lyrics_fetch(&cache_key) {
288 lyrics_debug!(
289 "inflight timed out, acquired retry key_hash={} waited_ms={}",
290 cache_key_hash,
291 wait_start.elapsed().as_millis()
292 );
293 Some(LyricsInflightGuard {
294 key: cache_key.clone(),
295 })
296 } else {
297 if let Some(cached) = lyrics_cache()
298 .lock()
299 .ok()
300 .and_then(|mut cache| cache.get_cloned(&cache_key))
301 {
302 lyrics_debug!(
303 "inflight final cache hit key_hash={} kind={}",
304 cache_key_hash,
305 lyrics_kind(cached.as_ref())
306 );
307 return cached;
308 }
309 lyrics_debug!(
310 "inflight unresolved key_hash={} waited_ms={}",
311 cache_key_hash,
312 wait_start.elapsed().as_millis()
313 );
314 return None;
315 }
316 };
317
318 let is_server = track_path.starts_with("jellyfin:")
319 || track_path.starts_with("subsonic:")
320 || track_path.starts_with("custom:");
321 let mut fallback: Option<Lyrics> = None;
322
323 if !is_server {
325 let started = Instant::now();
326 let local = fetch_local_lrc(track_path).await;
327 tracing::info!(
328 target: "kopuz::lyrics",
329 "local_lrc key_hash={} elapsed_ms={} kind={}",
330 log_lyrics_key_hash(&cache_key),
331 started.elapsed().as_millis(),
332 lyrics_kind(local.as_ref())
333 );
334 lyrics_debug!(
335 "provider=local_lrc elapsed_ms={} kind={}",
336 started.elapsed().as_millis(),
337 lyrics_kind(local.as_ref())
338 );
339 if let Some(lyrics) = local {
340 if has_word_timestamps(&lyrics) {
341 store_lyrics(&cache_key, &Some(lyrics.clone())).await;
342 tracing::info!(
343 target: "kopuz::lyrics",
344 "selected key_hash={} source=local_lrc kind={} total_ms={}",
345 log_lyrics_key_hash(&cache_key),
346 lyrics_kind(Some(&lyrics)),
347 total_start.elapsed().as_millis()
348 );
349 lyrics_debug!(
350 "selected source=local_lrc key_hash={} kind={} total_ms={}",
351 cache_key_hash,
352 lyrics_kind(Some(&lyrics)),
353 total_start.elapsed().as_millis()
354 );
355 return Some(lyrics);
356 }
357 fallback = Some(lyrics);
358 }
359 }
360
361 if prefer_local && !is_server {
362 store_lyrics(&cache_key, &fallback).await;
363 tracing::info!(
364 target: "kopuz::lyrics",
365 "selected key_hash={} source=prefer_local kind={} total_ms={}",
366 log_lyrics_key_hash(&cache_key),
367 lyrics_kind(fallback.as_ref()),
368 total_start.elapsed().as_millis()
369 );
370 lyrics_debug!(
371 "selected source=prefer_local key_hash={} kind={} total_ms={}",
372 cache_key_hash,
373 lyrics_kind(fallback.as_ref()),
374 total_start.elapsed().as_millis()
375 );
376 return fallback;
377 }
378
379 if let Some(server_url) = server_url {
381 if track_path.starts_with("jellyfin:") {
382 if let (Some(item_id), Some(token)) =
383 (extract_server_id(track_path, "jellyfin:"), server_token)
384 {
385 let started = Instant::now();
386 let server_lyrics = fetch_jellyfin_lyrics(&item_id, server_url, token).await;
387 tracing::info!(
388 target: "kopuz::lyrics",
389 "server_jellyfin key_hash={} elapsed_ms={} kind={}",
390 log_lyrics_key_hash(&cache_key),
391 started.elapsed().as_millis(),
392 lyrics_kind(server_lyrics.as_ref())
393 );
394 lyrics_debug!(
395 "provider=jellyfin elapsed_ms={} kind={}",
396 started.elapsed().as_millis(),
397 lyrics_kind(server_lyrics.as_ref())
398 );
399 if let Some(lyrics) = server_lyrics {
400 if has_word_timestamps(&lyrics) {
401 store_lyrics(&cache_key, &Some(lyrics.clone())).await;
402 tracing::info!(
403 target: "kopuz::lyrics",
404 "selected key_hash={} source=jellyfin kind={} total_ms={}",
405 log_lyrics_key_hash(&cache_key),
406 lyrics_kind(Some(&lyrics)),
407 total_start.elapsed().as_millis()
408 );
409 lyrics_debug!(
410 "selected source=jellyfin key_hash={} kind={} total_ms={}",
411 cache_key_hash,
412 lyrics_kind(Some(&lyrics)),
413 total_start.elapsed().as_millis()
414 );
415 return Some(lyrics);
416 }
417 fallback.get_or_insert(lyrics);
418 }
419 }
420 } else if track_path.starts_with("subsonic:") || track_path.starts_with("custom:") {
421 let prefix = if track_path.starts_with("subsonic:") {
422 "subsonic:"
423 } else {
424 "custom:"
425 };
426 if let (Some(song_id), Some(username), Some(password)) = (
427 extract_server_id(track_path, prefix),
428 server_user_id,
429 server_token,
430 ) {
431 let started = Instant::now();
432 let server_lyrics =
433 fetch_subsonic_lyrics(&song_id, server_url, username, password, artist, title)
434 .await;
435 tracing::info!(
436 target: "kopuz::lyrics",
437 "server_subsonic key_hash={} elapsed_ms={} kind={}",
438 log_lyrics_key_hash(&cache_key),
439 started.elapsed().as_millis(),
440 lyrics_kind(server_lyrics.as_ref())
441 );
442 lyrics_debug!(
443 "provider=subsonic elapsed_ms={} kind={}",
444 started.elapsed().as_millis(),
445 lyrics_kind(server_lyrics.as_ref())
446 );
447 if let Some(lyrics) = server_lyrics {
448 if has_word_timestamps(&lyrics) {
449 store_lyrics(&cache_key, &Some(lyrics.clone())).await;
450 tracing::info!(
451 target: "kopuz::lyrics",
452 "selected key_hash={} source=subsonic kind={} total_ms={}",
453 log_lyrics_key_hash(&cache_key),
454 lyrics_kind(Some(&lyrics)),
455 total_start.elapsed().as_millis()
456 );
457 lyrics_debug!(
458 "selected source=subsonic key_hash={} kind={} total_ms={}",
459 cache_key_hash,
460 lyrics_kind(Some(&lyrics)),
461 total_start.elapsed().as_millis()
462 );
463 return Some(lyrics);
464 }
465 fallback.get_or_insert(lyrics);
466 }
467 }
468 }
469 }
470
471 let apple_started = Instant::now();
475 let youtube_started = Instant::now();
476 let musixmatch_started = Instant::now();
477 let lrclib_started = Instant::now();
478 let apple = fetch_from_paxsenix_apple_music(artist, title, duration);
479 let youtube = fetch_from_paxsenix_youtube(artist, title, duration, track_path);
480 let musixmatch = fetch_from_musixmatch_enhanced(artist, title);
481 let lrclib = fetch_from_lrclib(artist, title, album, duration);
482 tokio::pin!(apple);
483 tokio::pin!(youtube);
484 tokio::pin!(musixmatch);
485 tokio::pin!(lrclib);
486
487 let mut apple_done = false;
488 let mut youtube_done = false;
489 let mut musixmatch_done = !enable_musixmatch;
490 let mut lrclib_done = !allow_lrclib;
491 let mut progressed: Option<Lyrics> = None;
492 let mut primary_quality = 0;
493 let mut musixmatch_candidate: Option<Lyrics> = None;
494 if !enable_musixmatch {
495 lyrics_debug!("provider=musixmatch skipped reason=disabled");
496 }
497 if !allow_lrclib {
498 lyrics_debug!("provider=lrclib skipped reason=disabled");
499 }
500
501 while !apple_done
502 || !youtube_done
503 || (!musixmatch_done && primary_quality < 2)
504 || (!lrclib_done
505 && lyrics_quality_option(fallback.as_ref())
506 .max(lyrics_quality_option(musixmatch_candidate.as_ref()))
507 < 1)
508 {
509 tokio::select! {
510 result = &mut apple, if !apple_done => {
511 apple_done = true;
512 tracing::info!(
513 target: "kopuz::lyrics",
514 "paxsenix_apple key_hash={} elapsed_ms={} kind={}",
515 log_lyrics_key_hash(&cache_key),
516 apple_started.elapsed().as_millis(),
517 lyrics_kind(result.as_ref())
518 );
519 lyrics_debug!(
520 "provider=paxsenix_apple elapsed_ms={} kind={}",
521 apple_started.elapsed().as_millis(),
522 lyrics_kind(result.as_ref())
523 );
524 if let Some(lyrics) = result {
525 let should_replace = fallback
526 .as_ref()
527 .map(|current| lyrics_quality(&lyrics) >= lyrics_quality(current))
528 .unwrap_or(true);
529 if should_replace {
530 fallback = Some(lyrics.clone());
531 }
532 primary_quality = primary_quality.max(lyrics_quality(&lyrics));
533 let should_progress = progressed
534 .as_ref()
535 .map(|current| lyrics_quality(&lyrics) >= lyrics_quality(current))
536 .unwrap_or(true);
537 if should_progress {
538 progressed = Some(lyrics.clone());
539 on_progress(lyrics);
540 }
541 }
542 }
543 result = &mut youtube, if !youtube_done => {
544 youtube_done = true;
545 tracing::info!(
546 target: "kopuz::lyrics",
547 "paxsenix_youtube key_hash={} elapsed_ms={} kind={}",
548 log_lyrics_key_hash(&cache_key),
549 youtube_started.elapsed().as_millis(),
550 lyrics_kind(result.as_ref())
551 );
552 lyrics_debug!(
553 "provider=paxsenix_youtube elapsed_ms={} kind={}",
554 youtube_started.elapsed().as_millis(),
555 lyrics_kind(result.as_ref())
556 );
557 if let Some(lyrics) = result {
558 let should_replace = fallback
559 .as_ref()
560 .map(|current| lyrics_quality(&lyrics) > lyrics_quality(current))
561 .unwrap_or(true);
562 if should_replace {
563 fallback = Some(lyrics.clone());
564 }
565 primary_quality = primary_quality.max(lyrics_quality(&lyrics));
566 let should_progress = progressed
567 .as_ref()
568 .map(|current| lyrics_quality(&lyrics) > lyrics_quality(current))
569 .unwrap_or(true);
570 if should_progress {
571 progressed = Some(lyrics.clone());
572 on_progress(lyrics);
573 }
574 }
575 }
576 result = &mut musixmatch, if !musixmatch_done && primary_quality < 2 => {
577 musixmatch_done = true;
578 tracing::info!(
579 target: "kopuz::lyrics",
580 "musixmatch_enhanced key_hash={} elapsed_ms={} kind={}",
581 log_lyrics_key_hash(&cache_key),
582 musixmatch_started.elapsed().as_millis(),
583 lyrics_kind(result.as_ref())
584 );
585 lyrics_debug!(
586 "provider=musixmatch elapsed_ms={} kind={}",
587 musixmatch_started.elapsed().as_millis(),
588 lyrics_kind(result.as_ref())
589 );
590 if let Some(lyrics) = result
591 {
592 musixmatch_candidate = Some(lyrics);
593 }
594 }
595 result = &mut lrclib, if !lrclib_done && lyrics_quality_option(fallback.as_ref()).max(lyrics_quality_option(musixmatch_candidate.as_ref())) < 1 => {
596 lrclib_done = true;
597 tracing::info!(
598 target: "kopuz::lyrics",
599 "lrclib key_hash={} elapsed_ms={} kind={}",
600 log_lyrics_key_hash(&cache_key),
601 lrclib_started.elapsed().as_millis(),
602 lyrics_kind(result.as_ref())
603 );
604 lyrics_debug!(
605 "provider=lrclib elapsed_ms={} kind={}",
606 lrclib_started.elapsed().as_millis(),
607 lyrics_kind(result.as_ref())
608 );
609 if let Some(lyrics) = result
610 && fallback
611 .as_ref()
612 .map(|current| lyrics_quality(&lyrics) > lyrics_quality(current))
613 .unwrap_or(true)
614 {
615 fallback = Some(lyrics.clone());
616 on_progress(lyrics);
617 }
618 }
619 }
620 }
621
622 if let Some(lyrics) = musixmatch_candidate
623 && fallback
624 .as_ref()
625 .map(|current| lyrics_quality(&lyrics) > lyrics_quality(current))
626 .unwrap_or(true)
627 {
628 fallback = Some(lyrics.clone());
629 on_progress(lyrics);
630 }
631
632 if enable_musixmatch && !musixmatch_done {
633 lyrics_debug!("provider=musixmatch skipped reason=primary_quality");
634 }
635
636 if allow_lrclib && !lrclib_done {
637 lyrics_debug!("provider=lrclib skipped reason=current_quality");
638 }
639
640 let fetched = fallback;
641 store_lyrics(&cache_key, &fetched).await;
642 tracing::info!(
643 target: "kopuz::lyrics",
644 "selected key_hash={} source=final kind={} total_ms={}",
645 log_lyrics_key_hash(&cache_key),
646 lyrics_kind(fetched.as_ref()),
647 total_start.elapsed().as_millis()
648 );
649 lyrics_debug!(
650 "selected source=final key_hash={} kind={} total_ms={}",
651 cache_key_hash,
652 lyrics_kind(fetched.as_ref()),
653 total_start.elapsed().as_millis()
654 );
655 fetched
656}
657
658pub fn cached_lyrics(
659 artist: &str,
660 title: &str,
661 album: &str,
662 duration: u64,
663 track_path: &str,
664 enable_musixmatch: bool,
665) -> Option<Option<Lyrics>> {
666 let cache_key = lyrics_cache_key(
667 artist,
668 title,
669 album,
670 duration,
671 track_path,
672 enable_musixmatch,
673 );
674 lyrics_cache()
675 .lock()
676 .ok()
677 .and_then(|mut cache| cache.get_cloned(&cache_key))
678}
679
680pub fn cached_lyrics_for_request(request: &LyricsRequest) -> Option<Option<Lyrics>> {
681 let cache_key = request.cache_key();
682 lyrics_cache()
683 .lock()
684 .ok()
685 .and_then(|mut cache| cache.get_cloned(&cache_key))
686}
687
688fn has_word_timestamps(lyrics: &Lyrics) -> bool {
689 match lyrics {
690 Lyrics::Synced(lines) => lines.iter().any(|line| line.chunks.len() > 1),
691 Lyrics::Plain(_) => false,
692 }
693}
694
695fn lyrics_quality(lyrics: &Lyrics) -> u8 {
696 match lyrics {
697 Lyrics::Synced(lines) if lines.iter().any(|line| line.chunks.len() > 1) => 2,
698 Lyrics::Synced(_) => 1,
699 Lyrics::Plain(_) => 0,
700 }
701}
702
703fn lyrics_quality_option(lyrics: Option<&Lyrics>) -> u8 {
704 lyrics.map(lyrics_quality).unwrap_or(0)
705}
706
707fn lyrics_kind(lyrics: Option<&Lyrics>) -> &'static str {
708 match lyrics {
709 Some(Lyrics::Synced(lines)) if lines.iter().any(|line| line.chunks.len() > 1) => {
710 "synced_word"
711 }
712 Some(Lyrics::Synced(_)) => "synced_line",
713 Some(Lyrics::Plain(_)) => "plain",
714 None => "none",
715 }
716}
717
718fn timed_line_count(lines: &[LyricLine]) -> usize {
719 lines.iter().filter(|line| !line.chunks.is_empty()).count()
720}
721
722fn timed_part_count(lines: &[LyricLine]) -> usize {
723 lines.iter().map(|line| line.chunks.len()).sum()
724}
725
726fn has_usable_line_timing(lines: &[LyricLine]) -> bool {
727 match lines {
728 [] => false,
729 [_] => true,
730 _ => lines
731 .windows(2)
732 .any(|pair| pair[1].start_time > pair[0].start_time),
733 }
734}
735
736fn lrc_has_usable_timing(lrc_text: &str) -> bool {
737 let mut timestamps = Vec::new();
738
739 for raw_line in lrc_text.lines() {
740 let (line_timestamps, content) = extract_line_timestamps(raw_line);
741 if !line_timestamps.is_empty() && !content.trim().is_empty() {
742 timestamps.extend(line_timestamps);
743 continue;
744 }
745
746 let (_, chunks) = parse_enhanced_words(content);
747 if !chunks.is_empty() {
748 timestamps.extend(chunks.into_iter().map(|chunk| chunk.start_time));
749 }
750 }
751
752 match timestamps.as_slice() {
753 [] => false,
754 [_] => true,
755 _ => timestamps.windows(2).any(|pair| pair[1] > pair[0]),
756 }
757}
758
759fn log_lyrics_key_hash(key: &str) -> String {
760 let mut hasher = DefaultHasher::new();
761 key.trim().hash(&mut hasher);
762 format!("{:016x}", hasher.finish())
763}
764
765fn lyrics_terminal_debug_enabled() -> bool {
766 std::env::var_os("KOPUZ_LYRICS_DEBUG").is_some()
767}
768
769fn lyrics_cache_key(
770 artist: &str,
771 title: &str,
772 album: &str,
773 duration: u64,
774 track_path: &str,
775 enable_musixmatch: bool,
776) -> String {
777 let provider_policy = if enable_musixmatch { "mm:on" } else { "mm:off" };
778 if !track_path.trim().is_empty() {
779 return format!("{}|{}", track_path.trim(), provider_policy);
780 }
781
782 format!(
783 "{}|{}|{}|{}|{}",
784 artist.trim().to_lowercase(),
785 title.trim().to_lowercase(),
786 album.trim().to_lowercase(),
787 duration,
788 provider_policy
789 )
790}
791
792fn extract_server_id(path: &str, prefix: &str) -> Option<String> {
793 path.strip_prefix(prefix)
794 .and_then(|rest| rest.split(':').next())
795 .filter(|s| !s.is_empty())
796 .map(|s| s.to_string())
797}
798
799fn lyrics_match_score(candidate: &str, query: &str) -> f64 {
800 let candidate_tokens = normalized_lyric_match_tokens(candidate);
801 let query_tokens = normalized_lyric_match_tokens(query);
802 if candidate_tokens.is_empty() || query_tokens.is_empty() {
803 return 0.0;
804 }
805
806 let shared = candidate_tokens
807 .iter()
808 .filter(|token| query_tokens.contains(*token))
809 .count();
810 (2 * shared) as f64 * 100.0 / (candidate_tokens.len() + query_tokens.len()) as f64
811}
812
813fn normalized_lyric_match_tokens(value: &str) -> HashSet<String> {
814 value
815 .to_lowercase()
816 .replace("(feat.", " ")
817 .replace("(ft.", " ")
818 .replace("(featuring", " ")
819 .split(|c: char| !c.is_alphanumeric())
820 .filter(|token| !token.is_empty())
821 .map(ToString::to_string)
822 .collect()
823}
824
825async fn fetch_from_lrclib(
826 artist: &str,
827 title: &str,
828 album: &str,
829 duration: u64,
830) -> Option<Lyrics> {
831 let mut url = format!(
832 "https://lrclib.net/api/get?artist_name={}&track_name={}",
833 percent_encoding::utf8_percent_encode(artist, NON_ALPHANUMERIC),
834 percent_encoding::utf8_percent_encode(title, NON_ALPHANUMERIC)
835 );
836
837 if !album.is_empty() {
838 url.push_str(&format!(
839 "&album_name={}",
840 percent_encoding::utf8_percent_encode(album, NON_ALPHANUMERIC)
841 ));
842 }
843 if duration > 0 {
844 url.push_str(&format!("&duration={}", duration));
845 }
846
847 let client = reqwest::Client::new();
848 let res = match client
849 .get(&url)
850 .header("User-Agent", concat!("Kopuz/", env!("CARGO_PKG_VERSION")))
851 .timeout(LRCLIB_TIMEOUT)
852 .send()
853 .await
854 {
855 Ok(res) => res,
856 Err(error) => {
857 tracing::info!(
858 target: "kopuz::lyrics",
859 "lrclib get failed={error}"
860 );
861 return None;
862 }
863 };
864
865 let mut fallback = None;
866
867 if res.status().is_success()
868 && let Ok(data) = res.json::<LrcLibResponse>().await
869 && let Some(lyrics) = extract_from_lrclib_response(&data)
870 {
871 if has_word_timestamps(&lyrics) {
872 return Some(lyrics);
873 }
874 fallback = Some(lyrics);
875 }
876
877 let search_url = format!(
878 "https://lrclib.net/api/search?track_name={}&artist_name={}",
879 percent_encoding::utf8_percent_encode(title, NON_ALPHANUMERIC),
880 percent_encoding::utf8_percent_encode(artist, NON_ALPHANUMERIC)
881 );
882 let search_res = match client
883 .get(&search_url)
884 .header("User-Agent", concat!("Kopuz/", env!("CARGO_PKG_VERSION")))
885 .timeout(LRCLIB_TIMEOUT)
886 .send()
887 .await
888 {
889 Ok(res) => res,
890 Err(error) => {
891 tracing::info!(
892 target: "kopuz::lyrics",
893 "lrclib search failed={error}"
894 );
895 return fallback;
896 }
897 };
898
899 if search_res.status().is_success()
900 && let Ok(results) = search_res.json::<Vec<LrcLibResponse>>().await
901 {
902 for data in results {
903 if let Some(lyrics) = extract_from_lrclib_response(&data) {
904 if has_word_timestamps(&lyrics) {
905 return Some(lyrics);
906 }
907 fallback.get_or_insert(lyrics);
908 }
909 }
910 }
911
912 fallback
913}
914
915fn extract_from_lrclib_response(data: &LrcLibResponse) -> Option<Lyrics> {
916 if let Some(synced) = &data.synced_lyrics
917 && !synced.trim().is_empty()
918 && lrc_has_usable_timing(synced)
919 {
920 return Some(Lyrics::Synced(parse_lrc(synced)));
921 }
922 if let Some(plain) = &data.plain_lyrics
923 && !plain.trim().is_empty()
924 {
925 return Some(Lyrics::Plain(plain.clone()));
926 }
927 None
928}
929
930#[cfg(test)]
931mod tests {
932 use super::musixmatch::musixmatch_richsync_to_lrc;
933 use super::paxsenix::{
934 best_itunes_song, best_youtube_result, extract_youtube_video_id, parse_colon_duration,
935 paxsenix_apple_to_lyrics,
936 };
937 use super::*;
938
939 #[test]
940 fn parses_regular_lrc_lines() {
941 let lines = parse_lrc("[00:01.00]Hello\n[00:02.50]World");
942
943 assert_eq!(lines.len(), 2);
944 assert_eq!(lines[0].start_time, 1.0);
945 assert_eq!(lines[0].text, "Hello");
946 assert!(lines[0].chunks.is_empty());
947 assert_eq!(lines[1].start_time, 2.5);
948 assert_eq!(lines[1].text, "World");
949 }
950
951 #[test]
952 fn parses_enhanced_lrc_word_timestamps() {
953 let lines = parse_lrc("[00:10.00]<00:10.10>Hello <00:10.60>world");
954
955 assert_eq!(lines.len(), 1);
956 assert_eq!(lines[0].start_time, 10.0);
957 assert_eq!(lines[0].text, "Hello world");
958 assert_eq!(lines[0].chunks.len(), 2);
959 assert_eq!(lines[0].chunks[0].start_time, 10.1);
960 assert_eq!(lines[0].chunks[0].text, "Hello ");
961 assert_eq!(lines[0].chunks[1].start_time, 10.6);
962 assert_eq!(lines[0].chunks[1].text, "world");
963 }
964
965 #[test]
966 fn duplicate_lrc_timestamps_preserve_chunks() {
967 let lines = parse_lrc("[00:10.00]Translation\n[00:10.00]<00:10.10>Hello <00:10.60>world");
968
969 assert_eq!(lines.len(), 1);
970 assert_eq!(lines[0].text, "Translation\n(Hello world)");
971 assert_eq!(lines[0].chunks.len(), 2);
972 assert_eq!(lines[0].chunks[0].start_time, 10.1);
973 assert_eq!(lines[0].chunks[0].text, "Hello ");
974 assert_eq!(lines[0].chunks[1].start_time, 10.6);
975 assert_eq!(lines[0].chunks[1].text, "world");
976 }
977
978 #[test]
979 fn detects_word_timed_lyrics() {
980 let line_only = Lyrics::Synced(parse_lrc("[00:01.00]Hello"));
981 let single_chunk = Lyrics::Synced(parse_lrc("[00:01.00]<00:01.10>Hello"));
982 let word_timed = Lyrics::Synced(parse_lrc("[00:01.00]<00:01.10>Hello <00:01.50>world"));
983
984 assert!(!has_word_timestamps(&line_only));
985 assert!(!has_word_timestamps(&single_chunk));
986 assert!(has_word_timestamps(&word_timed));
987 }
988
989 #[test]
990 fn lyrics_log_key_is_hashed() {
991 let key = "/Users/someone/Music/private-library/song.flac";
992 let logged = log_lyrics_key_hash(key);
993
994 assert_eq!(logged.len(), 16);
995 assert!(logged.chars().all(|c| c.is_ascii_hexdigit()));
996 assert!(!logged.contains("/Users"));
997 assert_ne!(logged, key);
998 }
999
1000 #[test]
1001 fn converts_musixmatch_richsync_to_enhanced_lrc() {
1002 let body = r#"[{"ts":10.0,"l":[{"o":0.1,"c":"Hello"},{"o":0.6,"c":"world"}]}]"#;
1003 let lrc = musixmatch_richsync_to_lrc(body).expect("richsync should convert");
1004 let parsed = parse_lrc(&lrc);
1005
1006 assert_eq!(parsed.len(), 1);
1007 assert_eq!(parsed[0].start_time, 10.0);
1008 assert_eq!(parsed[0].chunks.len(), 2);
1009 assert_eq!(parsed[0].chunks[0].start_time, 10.1);
1010 assert_eq!(parsed[0].chunks[0].text, "Hello ");
1011 assert_eq!(parsed[0].chunks[1].start_time, 10.6);
1012 assert_eq!(parsed[0].chunks[1].text, "world ");
1013 }
1014
1015 #[test]
1016 fn rejects_multi_line_lrc_without_progressing_timestamps() {
1017 assert!(lrc_has_usable_timing("[00:00.00]Only line"));
1018 assert!(lrc_has_usable_timing("[00:00.00]First\n[00:01.00]Second"));
1019 assert!(!lrc_has_usable_timing("[00:00.00]First\n[00:00.00]Second"));
1020 }
1021
1022 #[test]
1023 fn lrclib_falls_back_to_plain_when_synced_lrc_has_no_progressing_timestamps() {
1024 let response = LrcLibResponse {
1025 synced_lyrics: Some("[00:00.00]First\n[00:00.00]Second".to_string()),
1026 plain_lyrics: Some("First\nSecond".to_string()),
1027 };
1028
1029 let lyrics = extract_from_lrclib_response(&response).expect("plain lyrics should convert");
1030 assert_eq!(lyrics, Lyrics::Plain("First\nSecond".to_string()));
1031 }
1032
1033 #[test]
1034 fn extracts_youtube_video_id_from_track_path() {
1035 assert_eq!(
1036 extract_youtube_video_id("ytmusic:r9jGBwgzEzA:https%3A%2F%2Fimg"),
1037 Some("r9jGBwgzEzA".to_string())
1038 );
1039 assert_eq!(
1040 extract_youtube_video_id("ytmusic:r9jGBwgzEzA"),
1041 Some("r9jGBwgzEzA".to_string())
1042 );
1043 assert_eq!(extract_youtube_video_id("/music/song.flac"), None);
1044 }
1045
1046 #[test]
1047 fn parses_youtube_duration() {
1048 assert_eq!(parse_colon_duration("3:28"), Some(208));
1049 assert_eq!(parse_colon_duration("1:02:03"), Some(3723));
1050 assert_eq!(parse_colon_duration(""), None);
1051 assert_eq!(parse_colon_duration("nope"), None);
1052 }
1053
1054 #[test]
1055 fn youtube_selector_checks_duration() {
1056 let results = vec![
1057 PaxsenixYoutubeSearchResult {
1058 video_id: "wrong-duration".to_string(),
1059 title: "90210".to_string(),
1060 author: "blackbear".to_string(),
1061 duration: "5:40".to_string(),
1062 },
1063 PaxsenixYoutubeSearchResult {
1064 video_id: "right-duration".to_string(),
1065 title: "90210".to_string(),
1066 author: "blackbear".to_string(),
1067 duration: "3:28".to_string(),
1068 },
1069 ];
1070
1071 let selected = best_youtube_result(&results, "90210 blackbear", 208)
1072 .expect("a duration-matched result should be selected");
1073
1074 assert_eq!(selected.video_id, "right-duration");
1075 }
1076
1077 #[test]
1078 fn converts_paxsenix_apple_syllable_lyrics() {
1079 let response = PaxsenixAppleLyricsResponse {
1080 content: vec![PaxsenixAppleLyricLine {
1081 text: vec![
1082 PaxsenixAppleLyricPart {
1083 text: "Hel".to_string(),
1084 timestamp: Some(10100),
1085 part: true,
1086 },
1087 PaxsenixAppleLyricPart {
1088 text: "lo".to_string(),
1089 timestamp: Some(10300),
1090 part: false,
1091 },
1092 PaxsenixAppleLyricPart {
1093 text: "world".to_string(),
1094 timestamp: Some(10600),
1095 part: false,
1096 },
1097 ],
1098 background_text: Vec::new(),
1099 timestamp: 10000,
1100 endtime: Some(11000),
1101 background: false,
1102 opposite_turn: false,
1103 }],
1104 lrc: None,
1105 plain: None,
1106 };
1107
1108 let Lyrics::Synced(lines) =
1109 paxsenix_apple_to_lyrics(response).expect("lyrics should convert")
1110 else {
1111 panic!("apple lyrics should be synced");
1112 };
1113
1114 assert_eq!(lines.len(), 1);
1115 assert_eq!(lines[0].start_time, 10.0);
1116 assert_eq!(lines[0].text, "Hello world");
1117 assert_eq!(lines[0].chunks.len(), 3);
1118 assert_eq!(lines[0].chunks[0].start_time, 10.1);
1119 assert_eq!(lines[0].chunks[0].text, "Hel");
1120 assert_eq!(lines[0].chunks[2].start_time, 10.6);
1121 assert_eq!(lines[0].chunks[2].text, " world");
1122 }
1123
1124 #[test]
1125 fn skips_untimed_paxsenix_apple_content() {
1126 let response = PaxsenixAppleLyricsResponse {
1127 content: vec![PaxsenixAppleLyricLine {
1128 text: vec![PaxsenixAppleLyricPart {
1129 text: "Untimed".to_string(),
1130 timestamp: None,
1131 part: false,
1132 }],
1133 background_text: Vec::new(),
1134 timestamp: 0,
1135 endtime: Some(0),
1136 background: false,
1137 opposite_turn: false,
1138 }],
1139 lrc: None,
1140 plain: Some("Untimed".to_string()),
1141 };
1142
1143 let lyrics = paxsenix_apple_to_lyrics(response).expect("plain lyrics should convert");
1144 assert_eq!(lyrics, Lyrics::Plain("Untimed".to_string()));
1145 }
1146
1147 #[test]
1148 fn splits_paxsenix_apple_background_text() {
1149 let response = PaxsenixAppleLyricsResponse {
1150 content: vec![
1151 PaxsenixAppleLyricLine {
1152 text: vec![
1153 PaxsenixAppleLyricPart {
1154 text: "Looking".to_string(),
1155 timestamp: Some(10000),
1156 part: false,
1157 },
1158 PaxsenixAppleLyricPart {
1159 text: "for".to_string(),
1160 timestamp: Some(10500),
1161 part: false,
1162 },
1163 ],
1164 background_text: vec![PaxsenixAppleLyricPart {
1165 text: "Echo".to_string(),
1166 timestamp: Some(10800),
1167 part: false,
1168 }],
1169 timestamp: 10000,
1170 endtime: Some(12000),
1171 background: true,
1172 opposite_turn: true,
1173 },
1174 PaxsenixAppleLyricLine {
1175 text: vec![PaxsenixAppleLyricPart {
1176 text: "Next".to_string(),
1177 timestamp: Some(10600),
1178 part: false,
1179 }],
1180 background_text: Vec::new(),
1181 timestamp: 10600,
1182 endtime: Some(13000),
1183 background: false,
1184 opposite_turn: true,
1185 },
1186 ],
1187 lrc: None,
1188 plain: None,
1189 };
1190
1191 let Lyrics::Synced(lines) =
1192 paxsenix_apple_to_lyrics(response).expect("lyrics should convert")
1193 else {
1194 panic!("apple lyrics should be synced");
1195 };
1196
1197 assert_eq!(lines.len(), 3);
1198 assert_eq!(lines[0].text, "Looking for");
1199 assert!(!lines[0].background);
1200 assert!(lines[0].opposite_turn);
1201 assert_eq!(lines[0].parent_line_index, None);
1202 assert_eq!(lines[1].text, "Echo");
1203 assert!(lines[1].background);
1204 assert!(lines[1].opposite_turn);
1205 assert_eq!(lines[1].parent_line_index, Some(0));
1206 assert_eq!(lines[1].start_time, 10.8);
1207 assert_eq!(lines[2].text, "Next");
1208 assert_eq!(lines[2].parent_line_index, None);
1209 }
1210
1211 #[test]
1212 fn itunes_selector_checks_duration() {
1213 let songs = vec![
1214 ItunesSong {
1215 track_id: 1,
1216 track_name: "Somebody Told Me".to_string(),
1217 artist_name: "The Killers".to_string(),
1218 track_time_millis: Some(230_000),
1219 },
1220 ItunesSong {
1221 track_id: 2,
1222 track_name: "Somebody Told Me".to_string(),
1223 artist_name: "The Killers".to_string(),
1224 track_time_millis: Some(197_200),
1225 },
1226 ];
1227
1228 let selected = best_itunes_song(&songs, "Somebody Told Me The Killers", 198)
1229 .expect("a duration-matched result should be selected");
1230
1231 assert_eq!(selected.track_id, 2);
1232 }
1233}