Skip to main content

utils/
lyrics.rs

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// --- Apple Music lyrics types ---
51
52#[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// --- YouTube lyrics types ---
103
104#[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// --- Public API ---
114
115/// Fetch lyrics for a track, trying sources in priority order while preferring
116/// word-timed lyrics over line-only matches:
117/// 1. Local .lrc file alongside the audio file (local tracks only)
118/// 2. Jellyfin or Subsonic server lyrics API (server tracks)
119/// 3. Paxsenix Apple Music lyrics (syllable/line synced fallback for all tracks)
120/// 4. Paxsenix YouTube lyrics (direct video-id LRC for YouTube Music tracks)
121/// 5. Optional Musixmatch richsync fallback
122/// 6. lrclib.net (fallback for all tracks)
123///
124/// For Jellyfin: `server_token` = access token, `server_user_id` = user_id (unused for lyrics)
125/// For Subsonic: `server_token` = password, `server_user_id` = username
126// skip_all, not skip(track_path): a bare skip auto-records every other arg
127// as a span field, which would leak server_token (and url/user_id) into the
128// trace + log. Record only artist/title, explicitly.
129#[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    // Persistent layer: lyrics survive restarts — a DB hit skips the whole
245    // provider chain and seeds the in-memory LRU.
246    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    // 1. Local .lrc file (only for local tracks)
324    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    // 2. Server lyrics
380    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    // 3/4/5. Apple Music and direct YouTube lyrics are the primary remote
472    // providers. Optional Musixmatch starts with them, but can only replace
473    // the primary result when it returns strictly better timing quality.
474    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}