feat(lyrics): qbz-lyrics engine crate with Qobuz-first chain, LRC parse/emit, providers, cache

S2 of the Lyrics epic: new shared crate crates/qbz-lyrics (headless,
frontend-agnostic per ADR-006), the whole fetch/parse/cache engine in one
testable home for Slint, Tauri and the TUI.

- Domain model: LyricsDoc/LyricsLine/Word (native Qobuz wsync word stamps
  preserved) + the Tauri-wire-compatible LyricsPayload (camelCase serde);
  LyricsProvider gains the qobuz variant and round-trips it.
- LRC parser ported from the Tauri TS semantics (gap markers as end-of-vocal,
  8s MAX_SUNG cap, median last-line end) with the F5 defects fixed forward:
  multi-stamp lines split per stamp, [offset:] honored. Emitter renders wsync
  line bounds as LRC-with-gap-markers (Q5 persistence form, lossy at word
  level only).
- Providers ported verbatim from src-tauri/src/lyrics/providers.rs: LRCLIB
  search-first + scorer and lyrics.ovh, request shapes byte-identical.
- Cache: same per-user SQLite schema/path Tauri uses (WAL per ADR-002,
  no TTL) plus the additive qobuz_wsync_json column (amended Q5) with an
  idempotent in-place migration for Tauri-era DBs; readers prefer wsync.
  Stats read entries AND size from the per-user path (F1 fix).
- Orchestrator LyricsService: cache probe with uniform plain-only soft miss
  (Q4) -> Qobuz primary (wsync authoritative; plain held while LRCLIB is
  probed for synced, the no-sync-regression rule) -> LRCLIB with one retry
  on transport error -> lyrics.ovh; offline = cache-only with typed
  NotAvailableOffline (F3); in-flight dedupe via shared futures (F6);
  request-key echo on every response for the caller stale guard (F2).
- 55 unit tests: parser/emitter round-trips, scorer matrix, cache lifecycle
  + schema migration, chain priority/offline/dedupe with mocked providers.

cc
This commit is contained in:
vicrodh committed 2026-06-10 16:22:57 -06:00
1 parent 46c15f507b
commit d12039371b
10 files changed
+2925

No files matched your search

+381
View File
@@ -0,0 +1,381 @@
//! SQLite cache for lyrics.
//!
//! Same schema/path Tauri uses (`src-tauri/src/lyrics/cache.rs`, DB at
//! `<per-user cache dir>/lyrics/lyrics.db`): both frontends share one cache —
//! a track fetched in Tauri is warm in Slint and vice versa. WAL +
//! `synchronous=NORMAL` per ADR-002. No TTL, no eviction (clear-only),
//! upsert = `INSERT OR REPLACE`.
//!
//! ADDITIVE delta (amended Q5): column `qobuz_wsync_json` stores the native
//! word-synced document for `provider='qobuz'` entries. Tauri-era DBs are
//! migrated in place (`ALTER TABLE … ADD COLUMN`); old rows stay intact and
//! today's Tauri build keeps reading/writing the shared columns it knows.
use rusqlite::{params, Connection};
use std::path::{Path, PathBuf};
use crate::model::{LyricsPayload, LyricsProvider};
/// One cached row: the wire-compatible payload plus the native wsync column.
#[derive(Debug, Clone)]
pub struct CachedLyrics {
pub payload: LyricsPayload,
pub qobuz_wsync_json: Option<String>,
}
/// Cache stats (entries + on-disk size). Size is measured from the ACTUAL
/// per-user path this DB was opened at — fix-forward of defect F1 (Tauri's
/// `v2_lyrics_get_cache_stats` measured the stale pre-migration global path).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LyricsCacheStats {
pub entries: u64,
pub size_bytes: u64,
}
/// Database wrapper for lyrics cache
pub struct LyricsCacheDb {
conn: Connection,
path: PathBuf,
}
impl LyricsCacheDb {
/// Open or create the database
pub fn new(path: &Path) -> Result<Self, String> {
let conn = Connection::open(path)
.map_err(|e| format!("Failed to open lyrics cache database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for lyrics cache database: {}", e))?;
let db = Self {
conn,
path: path.to_path_buf(),
};
db.init_schema()?;
db.migrate_schema()?;
Ok(db)
}
fn init_schema(&self) -> Result<(), String> {
// Identical to Tauri's CREATE statements (`cache.rs:31-47`) so a
// shared DB created by either side converges; the additive column is
// handled uniformly by migrate_schema() for fresh AND existing DBs.
self.conn
.execute_batch(
"
CREATE TABLE IF NOT EXISTS lyrics_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER,
cache_key TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
duration_secs INTEGER,
plain TEXT,
synced_lrc TEXT,
provider TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_lyrics_track_id ON lyrics_cache(track_id);
CREATE INDEX IF NOT EXISTS idx_lyrics_cache_key ON lyrics_cache(cache_key);
",
)
.map_err(|e| format!("Failed to initialize lyrics cache schema: {}", e))?;
Ok(())
}
/// Additive migration: bring a Tauri-era DB up to the current schema.
fn migrate_schema(&self) -> Result<(), String> {
let has_wsync_column = self
.conn
.prepare("PRAGMA table_info(lyrics_cache)")
.and_then(|mut stmt| {
let names = stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?;
Ok(names.iter().any(|name| name == "qobuz_wsync_json"))
})
.map_err(|e| format!("Failed to inspect lyrics cache schema: {}", e))?;
if !has_wsync_column {
self.conn
.execute("ALTER TABLE lyrics_cache ADD COLUMN qobuz_wsync_json TEXT", [])
.map_err(|e| format!("Failed to add qobuz_wsync_json column: {}", e))?;
}
Ok(())
}
fn row_to_cached(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedLyrics> {
Ok(CachedLyrics {
payload: LyricsPayload {
track_id: row.get::<_, Option<i64>>(0)?.map(|v| v as u64),
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
duration_secs: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
plain: row.get(5)?,
synced_lrc: row.get(6)?,
provider: LyricsProvider::from_str(&row.get::<_, String>(7)?),
cached: true,
},
qobuz_wsync_json: row.get(8)?,
})
}
pub fn get_by_track_id(&self, track_id: u64) -> Result<Option<CachedLyrics>, String> {
let result = self.conn.query_row(
"SELECT track_id, title, artist, album, duration_secs, plain, synced_lrc, provider, qobuz_wsync_json
FROM lyrics_cache WHERE track_id = ?1",
params![track_id as i64],
Self::row_to_cached,
);
match result {
Ok(cached) => Ok(Some(cached)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to read cached lyrics: {}", e)),
}
}
pub fn get_by_cache_key(&self, cache_key: &str) -> Result<Option<CachedLyrics>, String> {
let result = self.conn.query_row(
"SELECT track_id, title, artist, album, duration_secs, plain, synced_lrc, provider, qobuz_wsync_json
FROM lyrics_cache WHERE cache_key = ?1",
params![cache_key],
Self::row_to_cached,
);
match result {
Ok(cached) => Ok(Some(cached)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to read cached lyrics: {}", e)),
}
}
pub fn upsert(
&self,
cache_key: &str,
payload: &LyricsPayload,
qobuz_wsync_json: Option<&str>,
) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO lyrics_cache
(track_id, cache_key, title, artist, album, duration_secs, plain, synced_lrc, provider, qobuz_wsync_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'), datetime('now'))",
params![
payload.track_id.map(|v| v as i64),
cache_key,
payload.title,
payload.artist,
payload.album,
payload.duration_secs.map(|v| v as i64),
payload.plain,
payload.synced_lrc,
payload.provider.as_str(),
qobuz_wsync_json,
],
)
.map_err(|e| format!("Failed to write lyrics cache: {}", e))?;
Ok(())
}
pub fn clear(&self) -> Result<(), String> {
self.conn
.execute("DELETE FROM lyrics_cache", [])
.map_err(|e| format!("Failed to clear lyrics cache: {}", e))?;
Ok(())
}
pub fn count_entries(&self) -> Result<u64, String> {
let count: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM lyrics_cache", [], |row| row.get(0))
.map_err(|e| format!("Failed to count lyrics cache entries: {}", e))?;
Ok(count.max(0) as u64)
}
/// On-disk size of THIS database (db + WAL + shm sidecars) — always the
/// per-user path the DB was opened at (F1 fix-forward).
pub fn size_bytes(&self) -> u64 {
let mut total = 0u64;
for suffix in ["", "-wal", "-shm"] {
let mut candidate = self.path.clone().into_os_string();
candidate.push(suffix);
if let Ok(meta) = std::fs::metadata(PathBuf::from(candidate)) {
total = total.saturating_add(meta.len());
}
}
total
}
pub fn stats(&self) -> Result<LyricsCacheStats, String> {
Ok(LyricsCacheStats {
entries: self.count_entries()?,
size_bytes: self.size_bytes(),
})
}
/// Path this DB is rooted at (diagnostics).
pub fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
fn payload(track_id: Option<u64>, provider: LyricsProvider) -> LyricsPayload {
LyricsPayload {
track_id,
title: "Title".into(),
artist: "Artist".into(),
album: Some("Album".into()),
duration_secs: Some(215),
plain: Some("plain text".into()),
synced_lrc: Some("[00:01.00] hi".into()),
provider,
cached: false,
}
}
fn temp_db_path(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join("lyrics.db")
}
#[test]
fn upsert_and_get_round_trip_by_id_and_key() {
let dir = tempfile::tempdir().unwrap();
let db = LyricsCacheDb::new(&temp_db_path(&dir)).unwrap();
let p = payload(Some(42), LyricsProvider::Lrclib);
db.upsert("artist::title::215", &p, None).unwrap();
let by_id = db.get_by_track_id(42).unwrap().expect("hit by track_id");
assert_eq!(by_id.payload.title, "Title");
assert!(by_id.payload.cached); // cache reads flag themselves
assert!(by_id.qobuz_wsync_json.is_none());
let by_key = db
.get_by_cache_key("artist::title::215")
.unwrap()
.expect("hit by key");
assert_eq!(by_key.payload.track_id, Some(42));
assert_eq!(by_key.payload.provider, LyricsProvider::Lrclib);
assert!(db.get_by_track_id(999).unwrap().is_none());
assert!(db.get_by_cache_key("nope").unwrap().is_none());
}
#[test]
fn wsync_column_persists_and_replace_clears_it() {
let dir = tempfile::tempdir().unwrap();
let db = LyricsCacheDb::new(&temp_db_path(&dir)).unwrap();
let p = payload(Some(7), LyricsProvider::Qobuz);
let wsync = r#"{"type":"wsync","lines":[{"line":"hi","start":1,"end":2}]}"#;
db.upsert("k", &p, Some(wsync)).unwrap();
let hit = db.get_by_track_id(7).unwrap().unwrap();
assert_eq!(hit.qobuz_wsync_json.as_deref(), Some(wsync));
assert_eq!(hit.payload.provider, LyricsProvider::Qobuz);
// INSERT OR REPLACE without wsync (e.g. an lrclib upgrade) clears it.
let p2 = payload(Some(7), LyricsProvider::Lrclib);
db.upsert("k", &p2, None).unwrap();
let hit = db.get_by_track_id(7).unwrap().unwrap();
assert!(hit.qobuz_wsync_json.is_none());
assert_eq!(db.count_entries().unwrap(), 1);
}
#[test]
fn migrates_tauri_era_schema_additively() {
let dir = tempfile::tempdir().unwrap();
let path = temp_db_path(&dir);
// Build the EXACT Tauri-era schema (src-tauri/src/lyrics/cache.rs:31-47,
// no qobuz_wsync_json) and seed a row with raw SQL.
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"
CREATE TABLE lyrics_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER,
cache_key TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
duration_secs INTEGER,
plain TEXT,
synced_lrc TEXT,
provider TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_lyrics_track_id ON lyrics_cache(track_id);
CREATE INDEX idx_lyrics_cache_key ON lyrics_cache(cache_key);
",
)
.unwrap();
conn.execute(
"INSERT INTO lyrics_cache (track_id, cache_key, title, artist, album, duration_secs, plain, synced_lrc, provider)
VALUES (1, 'old::row::100', 'Old', 'Row', NULL, 100, 'plain', '[00:01.00] x', 'lrclib')",
[],
)
.unwrap();
}
// Reopen through the crate: additive column appears, old rows intact.
let db = LyricsCacheDb::new(&path).unwrap();
let names: Vec<String> = db
.conn
.prepare("PRAGMA table_info(lyrics_cache)")
.unwrap()
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(names.iter().any(|n| n == "qobuz_wsync_json"));
let old = db.get_by_track_id(1).unwrap().expect("old row readable");
assert_eq!(old.payload.title, "Old");
assert_eq!(old.payload.synced_lrc.as_deref(), Some("[00:01.00] x"));
assert!(old.qobuz_wsync_json.is_none());
assert_eq!(db.count_entries().unwrap(), 1);
// New writes can use the column on the migrated DB.
db.upsert("new::row::1", &payload(Some(2), LyricsProvider::Qobuz), Some("{}"))
.unwrap();
let new = db.get_by_track_id(2).unwrap().unwrap();
assert_eq!(new.qobuz_wsync_json.as_deref(), Some("{}"));
// Reopening again is a no-op (migration is idempotent).
drop(db);
let db = LyricsCacheDb::new(&path).unwrap();
assert_eq!(db.count_entries().unwrap(), 2);
}
#[test]
fn clear_and_stats() {
let dir = tempfile::tempdir().unwrap();
let db = LyricsCacheDb::new(&temp_db_path(&dir)).unwrap();
db.upsert("a", &payload(Some(1), LyricsProvider::Ovh), None)
.unwrap();
db.upsert("b", &payload(Some(2), LyricsProvider::Lrclib), None)
.unwrap();
let stats = db.stats().unwrap();
assert_eq!(stats.entries, 2);
assert!(stats.size_bytes > 0); // measured at the real per-user path (F1)
db.clear().unwrap();
assert_eq!(db.count_entries().unwrap(), 0);
}
}
+45
View File
@@ -0,0 +1,45 @@
//! QBZ lyrics engine — headless, frontend-agnostic (ADR-006).
//!
//! One crate = the whole lyrics engine, reusable by Slint, Tauri and the TUI:
//!
//! - [`model`] — domain model ([`LyricsDoc`] structured lines, the
//! Tauri-wire-compatible [`LyricsPayload`], cache-key builder).
//! - [`lrc`] — LRC parser + emitter. One Rust home for the semantics the
//! Tauri frontend implemented in TypeScript (`src/lib/stores/lyricsStore.ts:131-185`):
//! empty-text gap markers as end-of-vocal bounds, the 8s `MAX_SUNG_MS` cap,
//! the median last-line end estimate. Fixes forward the F5 defects
//! (multi-stamp lines, `[offset:]` tag).
//! - [`wsync`] — Qobuz word-synced document mirror: the persistable native
//! form (additive `qobuz_wsync_json` cache column, amended Q5) and its
//! conversions to the domain model / LRC.
//! - [`providers`] — external fallback providers ported VERBATIM from
//! `src-tauri/src/lyrics/providers.rs`: LRCLIB (search-first + scorer) and
//! lyrics.ovh (plain-only). Request shapes are byte-identical.
//! - [`cache`] — per-user SQLite cache, same schema/path Tauri uses
//! (`<user cache dir>/lyrics/lyrics.db`, WAL per ADR-002) plus the additive
//! `qobuz_wsync_json` column.
//! - [`service`] — the Qobuz-first orchestrator
//! (spec `qbz-nix-docs/lyrics/2026-06-10-lyrics-slint-port-spec.md` §1.1):
//! cache probe (plain-only = soft miss) -> Qobuz primary -> LRCLIB ->
//! lyrics.ovh, offline cache-only mode, in-flight dedupe (F6), stale-guard
//! echo (F2 support).
//!
//! No frontend types anywhere below this line — the Slint/Tauri glue is a
//! thin adapter over [`service::LyricsService`].
pub mod cache;
pub mod lrc;
pub mod model;
pub mod providers;
pub mod service;
pub mod wsync;
pub use cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
pub use lrc::{emit_lrc, parse_lrc, parse_plain, LrcSpan, MAX_SUNG_MS};
pub use model::{build_cache_key, LyricsDoc, LyricsLine, LyricsPayload, LyricsProvider, Word};
pub use providers::LyricsData;
pub use service::{
HttpLyricsProviders, LyricsOutcome, LyricsProviders, LyricsRequest, LyricsResponse,
LyricsResult, LyricsService, LyricsSourceKind,
};
pub use wsync::{QobuzWsync, QobuzWsyncLine, QobuzWsyncWord};
+410
View File
@@ -0,0 +1,410 @@
//! LRC parser + emitter.
//!
//! The parser ports the TS semantics from `src/lib/stores/lyricsStore.ts:131-185`
//! verbatim: two-pass with empty-text gap markers as end-of-vocal bounds, the
//! `MAX_SUNG_MS = 8000` cap ("capping prevents the karaoke gradient from
//! creeping across silence"), and the median last-line end estimate.
//!
//! Fixed forward (defect F5, review §9.5):
//! - multi-timestamp lines (`[a][b]text`) are split — each stamp gets the
//! text (the TS regex leaked the second stamp into the displayed text);
//! - `[offset:±ms]` is honored: positive offset advances the display
//! (effective time = tag time − offset, the dominant player convention),
//! clamped at 0.
//!
//! The emitter renders synced spans (Qobuz wsync line bounds) into
//! LRC-with-gap-markers — the persisted lingua franca both frontends already
//! speak (Q5): `[mm:ss.xxx] text` from `start`; an empty `[mm:ss.xxx]` gap
//! marker from `end` whenever the vocal ends before the next line starts
//! (and after the final line). Lossy at WORD level only — the native word
//! stamps persist separately in the `qobuz_wsync_json` column (amended Q5).
use regex::Regex;
use std::sync::OnceLock;
use crate::model::LyricsLine;
/// Cap on any single line's sung duration (ms) — parity with the TS parser
/// (`lyricsStore.ts:154`). Anything beyond this is almost certainly an
/// instrumental gap the LRC didn't mark.
pub const MAX_SUNG_MS: i64 = 8000;
/// One synced span for the emitter: line start, optional end-of-vocal, text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LrcSpan {
pub start_ms: i64,
pub end_ms: Option<i64>,
pub text: String,
}
fn stamp_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
// Same shape as the TS regex (`lyricsStore.ts:137`) minus the trailing
// `(.*)`: text extraction is positional so consecutive stamps can be
// collected (F5 multi-stamp fix). Supports [mm:ss.xx], [mm:ss.xxx],
// [mm:ss], [mm:ss:xx].
RE.get_or_init(|| Regex::new(r"\[(\d{1,2}):(\d{2})(?:[.:](\d{2,3}))?\]").expect("valid regex"))
}
fn offset_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)^\s*\[offset\s*:\s*([+-]?\d+)\s*\]").expect("valid regex")
})
}
/// Parse an LRC blob into synced lines.
///
/// Two-pass: collect every timestamp (including empty-text gap markers like
/// `[02:34.00]` marking end-of-vocal before an instrumental break), then emit
/// only the text-bearing lines with each one's `end_ms` derived from the
/// following stamp (text OR gap), capped at `time_ms + MAX_SUNG_MS`.
pub fn parse_lrc(lrc: &str) -> Vec<LyricsLine> {
let stamp_re = stamp_regex();
let mut stamps: Vec<(i64, String)> = Vec::new();
let mut offset_ms: i64 = 0;
for raw_line in lrc.lines() {
// [offset:] tag — honored, F5 (last tag wins; applied globally below).
if let Some(caps) = offset_regex().captures(raw_line) {
if let Ok(value) = caps[1].parse::<i64>() {
offset_ms = value;
}
continue;
}
// Find the first stamp anywhere in the line (parity: the TS global
// regex also matched mid-line), then collect CONSECUTIVE stamps from
// there; the text after the last consecutive stamp applies to every
// collected stamp (F5 multi-stamp split).
let Some(first) = stamp_re.find(raw_line) else {
continue; // metadata tags ([ar:], [ti:], …) and junk lines skipped
};
let mut times: Vec<i64> = Vec::new();
let mut pos = first.start();
while let Some(caps) = stamp_re.captures_at(raw_line, pos) {
let whole = caps.get(0).expect("match 0");
if whole.start() != pos {
break; // non-adjacent stamp — belongs to the text
}
let minutes: i64 = caps[1].parse().unwrap_or(0);
let seconds: i64 = caps[2].parse().unwrap_or(0);
// Fractional part right-padded to ms ("50" -> 500ms), parity with
// the TS `padEnd(3, '0')` (`lyricsStore.ts:143`).
let ms: i64 = caps
.get(3)
.map(|frac| format!("{:0<3}", frac.as_str()).parse().unwrap_or(0))
.unwrap_or(0);
times.push((minutes * 60 + seconds) * 1000 + ms);
pos = whole.end();
}
let text = raw_line[pos..].trim().to_string();
for time_ms in times {
stamps.push((time_ms, text.clone()));
}
}
// Apply the offset to every stamp (positive = lyrics display earlier).
if offset_ms != 0 {
for stamp in &mut stamps {
stamp.0 = (stamp.0 - offset_ms).max(0);
}
}
// Stable sort keeps file order for equal timestamps (TS `Array.sort` on
// pre-sorted input behaves the same for this data).
stamps.sort_by_key(|stamp| stamp.0);
let mut lines: Vec<LyricsLine> = Vec::new();
for (i, stamp) in stamps.iter().enumerate() {
if stamp.1.is_empty() {
continue; // gap marker — never displayed, only bounds previous line
}
let cap = stamp.0 + MAX_SUNG_MS;
let end_ms = stamps.get(i + 1).map(|next| next.0.min(cap));
lines.push(LyricsLine {
time_ms: Some(stamp.0),
end_ms,
text: stamp.1.clone(),
words: None,
});
}
// Last line has no following stamp: estimate from the median of the
// preceding lines' sung durations, capped (`lyricsStore.ts:170-182`).
if lines.len() >= 2 && lines.last().map(|l| l.end_ms.is_none()).unwrap_or(false) {
let mut durations: Vec<i64> = Vec::new();
for i in 0..lines.len() - 1 {
let start = lines[i].time_ms.unwrap_or(0);
let bound = lines[i]
.end_ms
.or_else(|| lines[i + 1].time_ms)
.unwrap_or(start);
let d = bound - start;
if d > 0 {
durations.push(d);
}
}
if !durations.is_empty() {
durations.sort_unstable();
let median = durations[durations.len() / 2];
let last = lines.last_mut().expect("len >= 2");
last.end_ms = Some(last.time_ms.unwrap_or(0) + median.min(MAX_SUNG_MS));
}
}
lines
}
/// Parse plain lyrics (no timestamps): split on `\n`, trim, drop empties
/// (parity: `parsePlain`, `lyricsStore.ts:190-196`; `time_ms` is `None`
/// instead of the TS `0` — the domain model encodes "unsynced" explicitly).
pub fn parse_plain(plain: &str) -> Vec<LyricsLine> {
plain
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|text| LyricsLine {
time_ms: None,
end_ms: None,
text: text.to_string(),
words: None,
})
.collect()
}
fn format_stamp(ms: i64) -> String {
// Tracks beyond 99:59.999 would emit a 3-digit minute field the parser's
// `\d{1,2}` cannot read back — accepted edge (the native wsync column
// carries the exact data for Qobuz documents anyway).
let ms = ms.max(0);
let minutes = ms / 60_000;
let seconds = (ms % 60_000) / 1000;
let millis = ms % 1000;
format!("[{:02}:{:02}.{:03}]", minutes, seconds, millis)
}
/// Emit synced spans as LRC-with-gap-markers (Q5 persistence form).
///
/// - `[mm:ss.xxx] text` from each span's `start_ms`;
/// - an empty gap marker `[mm:ss.xxx]` from `end_ms` whenever the vocal ends
/// before the next span starts (real instrumental gap), and after the final
/// span — so the parser recovers the authoritative end-of-vocal for every
/// line (subject to its uniform `MAX_SUNG_MS` cap).
pub fn emit_lrc(spans: &[LrcSpan]) -> String {
let mut out = String::new();
for (i, span) in spans.iter().enumerate() {
out.push_str(&format_stamp(span.start_ms));
out.push(' ');
out.push_str(span.text.trim());
out.push('\n');
if let Some(end_ms) = span.end_ms {
if end_ms > span.start_ms {
let needs_marker = match spans.get(i + 1) {
Some(next) => end_ms < next.start_ms,
None => true, // trailing marker bounds the last line exactly
};
if needs_marker {
out.push_str(&format_stamp(end_ms));
out.push('\n');
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn line(lines: &[LyricsLine], i: usize) -> (i64, Option<i64>, &str) {
(
lines[i].time_ms.expect("synced line"),
lines[i].end_ms,
lines[i].text.as_str(),
)
}
#[test]
fn parses_all_stamp_formats() {
let lrc = "[00:01.50]a\n[00:03.500]b\n[00:05]c\n[00:07:25]d\n";
let lines = parse_lrc(lrc);
assert_eq!(lines.len(), 4);
assert_eq!(line(&lines, 0).0, 1500);
assert_eq!(line(&lines, 1).0, 3500);
assert_eq!(line(&lines, 2).0, 5000);
assert_eq!(line(&lines, 3).0, 7250); // colon fraction [mm:ss:xx]
}
#[test]
fn two_digit_fraction_pads_right_to_ms() {
let lines = parse_lrc("[00:01.05]a\n[00:02.5]b\n");
// ".05" -> 050ms. A single-digit fraction (".5") does NOT match
// (\d{2,3}) so that stamp is no stamp at all and the line is skipped
// — exactly what the TS regex did; keep parity.
assert_eq!(lines.len(), 1);
assert_eq!(line(&lines, 0).0, 1050);
}
#[test]
fn gap_marker_bounds_previous_line() {
let lrc = "[00:01.00]sung\n[00:03.00]\n[00:10.00]next\n";
let lines = parse_lrc(lrc);
assert_eq!(lines.len(), 2); // gap marker never displayed
assert_eq!(line(&lines, 0), (1000, Some(3000), "sung"));
assert_eq!(line(&lines, 1).2, "next");
}
#[test]
fn end_capped_at_max_sung_ms() {
let lrc = "[00:01.00]long line\n[00:20.00]later\n";
let lines = parse_lrc(lrc);
assert_eq!(lines[0].end_ms, Some(1000 + MAX_SUNG_MS));
}
#[test]
fn last_line_end_is_median_of_prior_durations_capped() {
// Durations: 2000, 4000 -> sorted [2000, 4000], median idx 1 = 4000.
let lrc = "[00:00.00]a\n[00:02.00]b\n[00:06.00]c\n";
let lines = parse_lrc(lrc);
assert_eq!(lines[2].end_ms, Some(6000 + 4000));
// Median above the cap clamps to MAX_SUNG_MS.
let lrc = "[00:00.00]a\n[00:09.00]b\n[00:18.00]c\n";
let lines = parse_lrc(lrc);
assert_eq!(lines[0].end_ms, Some(MAX_SUNG_MS)); // capped bound
assert_eq!(lines[2].end_ms, Some(18_000 + MAX_SUNG_MS));
}
#[test]
fn single_line_keeps_open_end() {
let lines = parse_lrc("[00:01.00]only\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].end_ms, None); // TS guard: lines.length >= 2
}
#[test]
fn multi_stamp_line_splits_per_stamp_f5() {
let lrc = "[00:10.00][00:50.00]chorus\n";
let lines = parse_lrc(lrc);
assert_eq!(lines.len(), 2);
assert_eq!(line(&lines, 0), (10_000, Some(10_000 + MAX_SUNG_MS), "chorus"));
assert_eq!(line(&lines, 1).0, 50_000);
// No stamp leaks into the displayed text.
assert!(!lines[0].text.contains('['));
}
#[test]
fn offset_tag_honored_f5() {
// Positive offset advances the display: effective = tag - offset.
let lrc = "[offset:+500]\n[00:01.00]a\n[00:03.00]b\n";
let lines = parse_lrc(lrc);
assert_eq!(line(&lines, 0).0, 500);
assert_eq!(line(&lines, 1).0, 2500);
// Negative offset delays; clamped at zero.
let lrc = "[offset:-250]\n[00:00.10]a\n[00:02.00]b\n";
let lines = parse_lrc(lrc);
assert_eq!(line(&lines, 0).0, 350);
let lrc = "[offset:2000]\n[00:01.00]a\n[00:05.00]b\n";
let lines = parse_lrc(lrc);
assert_eq!(line(&lines, 0).0, 0); // 1000 - 2000 clamped
}
#[test]
fn metadata_tags_skipped() {
let lrc = "[ar:Artist]\n[ti:Title]\n[al:Album]\n[00:01.00]real\n";
let lines = parse_lrc(lrc);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "real");
}
#[test]
fn stamps_sorted_by_time() {
let lrc = "[00:05.00]second\n[00:01.00]first\n";
let lines = parse_lrc(lrc);
assert_eq!(lines[0].text, "first");
assert_eq!(lines[1].text, "second");
assert_eq!(lines[0].end_ms, Some(5000)); // bounded by the later stamp
}
#[test]
fn parse_plain_trims_and_drops_empties() {
let lines = parse_plain("a\n\n b \n\t\nc");
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].text, "b");
assert!(lines.iter().all(|l| l.time_ms.is_none() && l.end_ms.is_none()));
}
#[test]
fn emitter_writes_gap_markers_only_for_real_gaps() {
let spans = vec![
LrcSpan {
start_ms: 1000,
end_ms: Some(3000),
text: "first".into(),
},
// contiguous: end == next start -> no marker
LrcSpan {
start_ms: 3000,
end_ms: Some(5000),
text: "second".into(),
},
// gap before this one (5000 < 9000) -> marker at 5000
LrcSpan {
start_ms: 9000,
end_ms: Some(10_000),
text: "third".into(),
},
];
let lrc = emit_lrc(&spans);
let expected = "[00:01.000] first\n[00:03.000] second\n[00:05.000]\n[00:09.000] third\n[00:10.000]\n";
assert_eq!(lrc, expected);
}
#[test]
fn emit_parse_round_trip_preserves_bounds() {
let spans = vec![
LrcSpan {
start_ms: 1750,
end_ms: Some(4770),
text: "Oh, mm-mm".into(),
},
LrcSpan {
start_ms: 19_690,
end_ms: Some(22_180),
text: "Fly me to the moon".into(),
},
];
let lines = parse_lrc(&emit_lrc(&spans));
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].time_ms, Some(1750));
assert_eq!(lines[0].end_ms, Some(4770)); // gap marker carried the end
assert_eq!(lines[1].time_ms, Some(19_690));
// Trailing marker bounds the LAST line with the exact end too.
assert_eq!(lines[1].end_ms, Some(22_180));
}
#[test]
fn round_trip_applies_uniform_cap_on_long_vocals() {
// A single vocal sung >8s: the emitter writes the true end, the
// parser caps it — the accepted uniform heuristic (Q5).
let spans = vec![
LrcSpan {
start_ms: 0,
end_ms: Some(12_000),
text: "looooong".into(),
},
LrcSpan {
start_ms: 15_000,
end_ms: Some(16_000),
text: "after".into(),
},
];
let lines = parse_lrc(&emit_lrc(&spans));
assert_eq!(lines[0].end_ms, Some(MAX_SUNG_MS));
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Lyrics domain model.
//!
//! [`LyricsPayload`] keeps the EXACT camelCase serde shape of Tauri's
//! `src-tauri/src/lyrics/mod.rs:15-27` so a later re-pointed `v2_lyrics_get`
//! stays wire-identical for the Svelte store (spec §2.2.2). [`LyricsDoc`] is
//! the structured form both frontends consume — the service parses internally
//! (native wsync or LRC) and returns structured lines, so no frontend
//! re-implements the TS parser.
use serde::{Deserialize, Serialize};
use crate::lrc;
/// Lyrics source provider. Tauri's enum (`src-tauri/src/lyrics/mod.rs:29-50`)
/// plus the new first-party `Qobuz` variant; serialized lowercase
/// (`'lrclib' | 'ovh' | 'qobuz'` on the JS side).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LyricsProvider {
Lrclib,
Ovh,
Qobuz,
}
impl LyricsProvider {
pub fn as_str(&self) -> &'static str {
match self {
Self::Lrclib => "lrclib",
Self::Ovh => "ovh",
Self::Qobuz => "qobuz",
}
}
/// Parse a stored provider string. Unknown values collapse to `Lrclib`
/// (parity with Tauri `mod.rs:44-49`); `"qobuz"` round-trips (spec §1.4).
#[allow(clippy::should_implement_trait)]
pub fn from_str(value: &str) -> Self {
match value {
"ovh" => Self::Ovh,
"qobuz" => Self::Qobuz,
_ => Self::Lrclib,
}
}
}
/// Wire-compatible lyrics payload (Tauri `LyricsPayload`, camelCase serde).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LyricsPayload {
pub track_id: Option<u64>,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub duration_secs: Option<u64>,
pub plain: Option<String>,
pub synced_lrc: Option<String>,
pub provider: LyricsProvider,
pub cached: bool,
}
/// One word inside a synced line (Qobuz wsync only; `start`/`end` are ms from
/// track start). Word-level timing is richer than LRC — preserved natively in
/// the model for word-anchored karaoke fill (spec Addendum 2, Q2 enriched).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Word {
pub start: i64,
pub end: i64,
pub text: String,
}
/// One lyrics line in the domain model.
///
/// - Synced lines carry `time_ms` (and usually `end_ms`); plain lines carry
/// neither.
/// - `words` is `Some` only for Qobuz wsync documents.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LyricsLine {
pub time_ms: Option<i64>,
pub end_ms: Option<i64>,
pub text: String,
pub words: Option<Vec<Word>>,
}
/// Parsed, render-ready lyrics document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LyricsDoc {
pub lines: Vec<LyricsLine>,
pub synced: bool,
pub provider: LyricsProvider,
/// ISO 639-1 codes Qobuz offers translations for (metadata passthrough;
/// empty for non-Qobuz providers).
pub translation_langs: Vec<String>,
/// Songwriter credits display string (Qobuz metadata passthrough).
pub writers: Option<String>,
}
impl LyricsDoc {
/// Empty document (status "no lyrics").
pub fn empty(provider: LyricsProvider) -> Self {
Self {
lines: Vec::new(),
synced: false,
provider,
translation_langs: Vec::new(),
writers: None,
}
}
/// Build from an LRC text blob (fallback-provider path).
pub fn from_lrc(lrc_text: &str, provider: LyricsProvider) -> Self {
let lines = lrc::parse_lrc(lrc_text);
let synced = !lines.is_empty();
Self {
lines,
synced,
provider,
translation_langs: Vec::new(),
writers: None,
}
}
/// Build from a plain text blob (no timestamps).
pub fn from_plain_text(plain: &str, provider: LyricsProvider) -> Self {
Self {
lines: lrc::parse_plain(plain),
synced: false,
provider,
translation_langs: Vec::new(),
writers: None,
}
}
/// Build from a wire payload. Synced wins over plain: try `synced_lrc`
/// first; fall to `plain` only when the LRC parse produced zero lines
/// (parity with the TS `parsePayload`, `lyricsStore.ts:201-214`).
pub fn from_payload(payload: &LyricsPayload) -> Self {
if let Some(lrc_text) = payload.synced_lrc.as_deref() {
if !lrc_text.trim().is_empty() {
let doc = Self::from_lrc(lrc_text, payload.provider);
if !doc.lines.is_empty() {
return doc;
}
}
}
if let Some(plain) = payload.plain.as_deref() {
if !plain.trim().is_empty() {
return Self::from_plain_text(plain, payload.provider);
}
}
Self::empty(payload.provider)
}
/// Build from a cached row. Readers PREFER the native wsync document when
/// present (amended Q5): word timestamps survive the cache; the LRC column
/// stays the cross-frontend lingua franca.
pub fn from_cached(payload: &LyricsPayload, qobuz_wsync_json: Option<&str>) -> Self {
if payload.provider == LyricsProvider::Qobuz {
if let Some(json) = qobuz_wsync_json {
match serde_json::from_str::<crate::wsync::QobuzWsync>(json) {
Ok(wsync) => {
let doc = wsync.to_doc();
if !doc.lines.is_empty() {
return doc;
}
}
Err(e) => {
log::warn!("[Lyrics] unparsable cached wsync json ({}); falling back to LRC", e);
}
}
}
}
Self::from_payload(payload)
}
/// Join line texts with `\n` (uniform plain rendering / copy-lyrics).
pub fn plain_text(&self) -> String {
self.lines
.iter()
.map(|line| line.text.as_str())
.collect::<Vec<_>>()
.join("\n")
}
}
/// Cache key: `"{norm_artist}::{norm_title}::{duration_or_0}"`
/// (parity: Tauri `mod.rs:98-103`).
pub fn build_cache_key(title: &str, artist: &str, duration_secs: Option<u64>) -> String {
let normalized_title = normalize(title);
let normalized_artist = normalize(artist);
let duration = duration_secs.unwrap_or(0);
format!("{}::{}::{}", normalized_artist, normalized_title, duration)
}
/// Normalize = lowercase + whitespace-collapse (parity: Tauri `mod.rs:105-111`).
pub fn normalize(value: &str) -> String {
value
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_normalizes_and_defaults_duration() {
assert_eq!(
build_cache_key(" My Song ", "The ARTIST", Some(215)),
"the artist::my song::215"
);
assert_eq!(build_cache_key("A", "B", None), "b::a::0");
}
#[test]
fn provider_round_trips_qobuz_and_collapses_unknowns() {
for provider in [
LyricsProvider::Lrclib,
LyricsProvider::Ovh,
LyricsProvider::Qobuz,
] {
assert_eq!(LyricsProvider::from_str(provider.as_str()), provider);
}
// Unknown strings collapse to Lrclib (Tauri parity).
assert_eq!(LyricsProvider::from_str("musixmatch"), LyricsProvider::Lrclib);
}
#[test]
fn payload_serde_shape_is_tauri_wire_compatible() {
let payload = LyricsPayload {
track_id: Some(42),
title: "T".into(),
artist: "A".into(),
album: None,
duration_secs: Some(200),
plain: Some("p".into()),
synced_lrc: None,
provider: LyricsProvider::Qobuz,
cached: true,
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["trackId"], 42);
assert_eq!(json["durationSecs"], 200);
assert_eq!(json["syncedLrc"], serde_json::Value::Null);
assert_eq!(json["provider"], "qobuz");
assert_eq!(json["cached"], true);
}
#[test]
fn from_payload_prefers_synced_falls_back_to_plain() {
let mut payload = LyricsPayload {
track_id: None,
title: "T".into(),
artist: "A".into(),
album: None,
duration_secs: None,
plain: Some("line one\nline two".into()),
synced_lrc: Some("[00:01.00] hello\n[00:03.00] world".into()),
provider: LyricsProvider::Lrclib,
cached: false,
};
let doc = LyricsDoc::from_payload(&payload);
assert!(doc.synced);
assert_eq!(doc.lines.len(), 2);
assert_eq!(doc.lines[0].text, "hello");
// Garbage LRC (zero parsed lines) falls back to plain.
payload.synced_lrc = Some("no timestamps here".into());
let doc = LyricsDoc::from_payload(&payload);
assert!(!doc.synced);
assert_eq!(doc.lines.len(), 2);
assert_eq!(doc.lines[0].text, "line one");
assert_eq!(doc.lines[0].time_ms, None);
// Nothing usable -> empty.
payload.synced_lrc = None;
payload.plain = Some(" ".into());
let doc = LyricsDoc::from_payload(&payload);
assert!(doc.lines.is_empty());
}
#[test]
fn from_cached_prefers_wsync_over_lrc() {
let payload = LyricsPayload {
track_id: Some(1),
title: "T".into(),
artist: "A".into(),
album: None,
duration_secs: None,
plain: Some("hello".into()),
synced_lrc: Some("[00:01.00] hello".into()),
provider: LyricsProvider::Qobuz,
cached: true,
};
let wsync_json = r#"{"type":"wsync","lang":"en","lines":[
{"line":"hello","start":1000,"end":2000,
"words":[{"word":"hello","start":1000,"end":2000}]}
]}"#;
let doc = LyricsDoc::from_cached(&payload, Some(wsync_json));
assert!(doc.synced);
let words = doc.lines[0].words.as_ref().expect("native words preserved");
assert_eq!(words[0].text, "hello");
// Without the column, the LRC path applies (no words).
let doc = LyricsDoc::from_cached(&payload, None);
assert!(doc.synced);
assert!(doc.lines[0].words.is_none());
// Corrupt wsync json degrades to the LRC path instead of erroring.
let doc = LyricsDoc::from_cached(&payload, Some("{not json"));
assert!(doc.synced);
assert!(doc.lines[0].words.is_none());
}
#[test]
fn plain_text_joins_lines() {
let doc = LyricsDoc::from_plain_text("a\n\n b \nc", LyricsProvider::Ovh);
assert_eq!(doc.plain_text(), "a\nb\nc");
}
}
+416
View File
@@ -0,0 +1,416 @@
//! External fallback lyrics providers.
//!
//! Ported VERBATIM from `src-tauri/src/lyrics/providers.rs` (request shapes
//! byte-identical: endpoints, query params, User-Agent, 10s timeout). The
//! only change is `eprintln!`/`println!` -> `log::` macros (crate
//! convention); the transport/miss contract (`Ok(None)` vs `Err`) is
//! unchanged — only `Err` triggers the orchestrator's single retry.
use reqwest::Client;
use serde::Deserialize;
use std::time::Duration;
use urlencoding::encode;
use crate::model::{normalize, LyricsProvider};
/// Build a shared HTTP client with reasonable timeout
fn build_client() -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))
}
#[derive(Debug, Clone)]
pub struct LyricsData {
pub plain: Option<String>,
pub synced_lrc: Option<String>,
pub provider: LyricsProvider,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub(crate) struct LrclibItem {
#[serde(default)]
pub track_name: String,
#[serde(default)]
pub artist_name: String,
pub album_name: Option<String>,
pub duration: Option<f64>,
pub instrumental: Option<bool>,
pub plain_lyrics: Option<String>,
pub synced_lyrics: Option<String>,
}
/// Fetch lyrics from LRCLIB (search-first, GET as fallback).
///
/// Search returns multiple candidates so we can pick the one with synced
/// lyrics. GET is only used as fallback when search returns nothing.
///
/// Returns:
/// - `Ok(Some(data))` — lyrics found
/// - `Ok(None)` — not found (API responded but no match)
/// - `Err(msg)` — network/transport error (caller should retry)
pub async fn fetch_lrclib(
title: &str,
artist: &str,
duration_secs: Option<u64>,
) -> Result<Option<LyricsData>, String> {
let client = build_client()?;
let mut had_network_error = false;
// Search first — returns multiple candidates, pick_best_match prioritises synced
let results = match fetch_lrclib_search(&client, title, artist).await {
Ok(items) => items,
Err(e) => {
log::warn!("[Lyrics] LRCLIB search failed (will try GET): {}", e);
had_network_error = true;
Vec::new()
}
};
let mut best = pick_best_match(&results, title, artist, duration_secs);
// Fall back to exact-match GET only when search yielded nothing
if best.is_none() {
match fetch_lrclib_get(&client, title, artist).await {
Ok(item) => best = item,
Err(e) => {
log::warn!("[Lyrics] LRCLIB GET fallback failed: {}", e);
had_network_error = true;
}
}
}
let Some(item) = best else {
if had_network_error {
return Err("LRCLIB requests failed due to network errors".to_string());
}
return Ok(None);
};
if item.instrumental.unwrap_or(false) {
return Ok(None);
}
let plain = item.plain_lyrics.and_then(clean_lyrics);
let synced = item.synced_lyrics.and_then(clean_lyrics);
if plain.is_none() && synced.is_none() {
return Ok(None);
}
Ok(Some(LyricsData {
plain,
synced_lrc: synced,
provider: LyricsProvider::Lrclib,
}))
}
pub async fn fetch_lyrics_ovh(title: &str, artist: &str) -> Option<LyricsData> {
let client = match build_client() {
Ok(c) => c,
Err(e) => {
log::warn!("[Lyrics] {}", e);
return None;
}
};
let artist_encoded = encode(artist);
let title_encoded = encode(title);
let url = format!(
"https://api.lyrics.ovh/v1/{}/{}",
artist_encoded, title_encoded
);
let response = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
log::warn!("[Lyrics] lyrics.ovh request failed: {}", e);
return None;
}
};
if !response.status().is_success() {
return None;
}
#[derive(Deserialize)]
struct OvhResponse {
lyrics: Option<String>,
}
let data: OvhResponse = match response.json().await {
Ok(d) => d,
Err(e) => {
log::warn!("[Lyrics] lyrics.ovh response parse failed: {}", e);
return None;
}
};
let plain = data.lyrics.and_then(clean_lyrics);
plain.as_ref()?;
Some(LyricsData {
plain,
synced_lrc: None,
provider: LyricsProvider::Ovh,
})
}
async fn fetch_lrclib_get(
client: &Client,
title: &str,
artist: &str,
) -> Result<Option<LrclibItem>, String> {
let response = client
.get("https://lrclib.net/api/get")
.header("User-Agent", "QBZ-Nix/1.0 (https://github.com/qbz-nix)")
.query(&[("track_name", title), ("artist_name", artist)])
.send()
.await
.map_err(|e| format!("LRCLIB get request failed: {}", e))?;
if !response.status().is_success() {
log::debug!("[Lyrics] LRCLIB get returned status: {}", response.status());
return Ok(None);
}
// Get raw text first for debugging
let text = response
.text()
.await
.map_err(|e| format!("LRCLIB get response text failed: {}", e))?;
// Log if syncedLyrics is present in raw response
let has_synced = text.contains("syncedLyrics") && !text.contains("\"syncedLyrics\":null");
log::debug!(
"[Lyrics] LRCLIB get raw response has syncedLyrics: {}, len: {}",
has_synced,
text.len()
);
let item: LrclibItem = serde_json::from_str(&text)
.map_err(|e| format!("LRCLIB get response parse failed: {}", e))?;
log::debug!(
"[Lyrics] LRCLIB parsed - synced_lyrics present: {}",
item.synced_lyrics.is_some()
);
Ok(Some(item))
}
async fn fetch_lrclib_search(
client: &Client,
title: &str,
artist: &str,
) -> Result<Vec<LrclibItem>, String> {
let response = client
.get("https://lrclib.net/api/search")
.header("User-Agent", "QBZ-Nix/1.0 (https://github.com/qbz-nix)")
.query(&[("track_name", title), ("artist_name", artist)])
.send()
.await
.map_err(|e| format!("LRCLIB search request failed: {}", e))?;
if !response.status().is_success() {
return Ok(Vec::new());
}
let items: Vec<LrclibItem> = response
.json()
.await
.map_err(|e| format!("LRCLIB search response parse failed: {}", e))?;
Ok(items)
}
pub(crate) fn pick_best_match(
items: &[LrclibItem],
title: &str,
artist: &str,
duration_secs: Option<u64>,
) -> Option<LrclibItem> {
let normalized_title = normalize(title);
let normalized_artist = normalize(artist);
let target_duration = duration_secs.unwrap_or(0) as f64;
let mut best: Option<(i32, &LrclibItem)> = None;
for item in items {
let item_title = normalize(&item.track_name);
let item_artist = normalize(&item.artist_name);
let mut score = 0;
if item_title == normalized_title {
score += 3;
}
if item_artist == normalized_artist {
score += 3;
}
if item_title == normalized_title && item_artist == normalized_artist {
score += 4;
}
if let Some(duration) = item.duration {
if target_duration > 0.0 {
let diff = (duration - target_duration).abs();
if diff <= 2.0 {
score += 3;
} else if diff <= 5.0 {
score += 1;
}
}
}
if item
.synced_lyrics
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
score += 2;
} else if item
.plain_lyrics
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
score += 1;
}
match best {
Some((best_score, _)) if score <= best_score => {}
_ => best = Some((score, item)),
}
}
best.map(|(_, item)| item.clone())
}
fn clean_lyrics(value: String) -> Option<String> {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(
track: &str,
artist: &str,
duration: Option<f64>,
synced: Option<&str>,
plain: Option<&str>,
) -> LrclibItem {
LrclibItem {
track_name: track.to_string(),
artist_name: artist.to_string(),
album_name: None,
duration,
instrumental: None,
plain_lyrics: plain.map(str::to_string),
synced_lyrics: synced.map(str::to_string),
}
}
// Tauri's providers.rs ships no tests — this is the scorer matrix
// written from the documented weights (review §1.3): title==+3,
// artist==+3, both +4 extra (exact pair = 10), duration |Δ|<=2s +3 /
// <=5s +1, synced +2 / plain +1, ties keep the earlier candidate.
#[test]
fn exact_pair_beats_partial_matches() {
let items = vec![
item("Song", "Other Artist", None, Some("[00:01.00]x"), None), // 3+2=5
item("Song", "Artist", None, None, Some("text")), // 3+3+4+1=11
item("Other Song", "Artist", None, Some("[00:01.00]x"), None), // 3+2=5
];
let best = pick_best_match(&items, "Song", "Artist", None).unwrap();
assert_eq!(best.track_name, "Song");
assert_eq!(best.artist_name, "Artist");
}
#[test]
fn duration_tiers_score_3_then_1() {
// Same title/artist; only duration differs. <=2s diff (+3) must beat
// <=5s diff (+1) and >5s (+0).
let items = vec![
item("S", "A", Some(210.0), None, Some("p")), // diff 10 -> +0
item("S", "A", Some(204.0), None, Some("p")), // diff 4 -> +1
item("S", "A", Some(201.0), None, Some("p")), // diff 1 -> +3
];
let best = pick_best_match(&items, "S", "A", Some(200)).unwrap();
assert_eq!(best.duration, Some(201.0));
// Without a target duration there is no duration scoring at all:
// ties keep the earlier candidate.
let best = pick_best_match(&items, "S", "A", None).unwrap();
assert_eq!(best.duration, Some(210.0));
}
#[test]
fn synced_outranks_plain() {
let items = vec![
item("S", "A", None, None, Some("plain text")), // +1
item("S", "A", None, Some("[00:01.00]hi"), None), // +2
];
let best = pick_best_match(&items, "S", "A", None).unwrap();
assert!(best.synced_lyrics.is_some());
// Whitespace-only synced does NOT earn the synced bonus.
let items = vec![
item("S", "A", None, Some(" "), Some("plain")), // plain bonus +1
item("S", "A", None, None, Some("plain")), // +1, tie -> earlier wins
];
let best = pick_best_match(&items, "S", "A", None).unwrap();
assert_eq!(best.synced_lyrics.as_deref(), Some(" "));
}
#[test]
fn ties_keep_the_earlier_candidate() {
let items = vec![
item("S", "A", None, None, Some("first")),
item("S", "A", None, None, Some("second")),
];
let best = pick_best_match(&items, "S", "A", None).unwrap();
assert_eq!(best.plain_lyrics.as_deref(), Some("first"));
}
#[test]
fn normalization_is_lowercase_whitespace_collapse() {
let items = vec![item(" MY song ", "THE artist", None, None, Some("p"))];
let best = pick_best_match(&items, "my song", "the ARTIST", None).unwrap();
assert_eq!(best.plain_lyrics.as_deref(), Some("p"));
// Sanity: that match scored as the exact pair (10 + plain 1), not 0 —
// a non-matching item would still be returned (best of one), so
// verify against a competitor.
let items = vec![
item("unrelated", "nobody", None, Some("[00:01.00]x"), None), // +2
item(" MY song ", "THE artist", None, None, Some("p")), // 11
];
let best = pick_best_match(&items, "my song", "the artist", None).unwrap();
assert_eq!(best.plain_lyrics.as_deref(), Some("p"));
}
#[test]
fn empty_candidate_list_yields_none() {
assert!(pick_best_match(&[], "S", "A", None).is_none());
}
#[test]
fn clean_lyrics_trims_and_drops_whitespace_only() {
assert_eq!(clean_lyrics(" hi ".into()).as_deref(), Some("hi"));
assert!(clean_lyrics(" \n\t ".into()).is_none());
assert!(clean_lyrics(String::new()).is_none());
}
}
File diff suppressed because it is too large. Load diff
+285
View File
@@ -0,0 +1,285 @@
//! Qobuz word-synced (wsync) document — the persistable native form.
//!
//! Amended Q5 (spec Addendum 2): the cache keeps the shared `synced_lrc`
//! column (Tauri-compat, LRC-with-gap-markers emission — lossy at word level
//! only) AND an additive `qobuz_wsync_json` column holding this document.
//! Readers prefer wsync when present.
//!
//! The serialized shape mirrors the Qobuz wire content union exactly
//! (`{"type":"wsync","lang":…,"lines":[{"line","start","end","words":[{"word","start","end"}]}]}`,
//! live captures `qbz-nix-docs/qobuz-api/lyrics-doc-wsync.json`), so a wire
//! content union parses as a [`QobuzWsync`] and a stored [`QobuzWsync`] is
//! wire-shaped. The Qobuz wrapper metadata (`translation_langs`, `writers`)
//! is carried as additive optional fields so it survives the cache.
use serde::{Deserialize, Serialize};
use qbz_qobuz::{QobuzLyricsContent, QobuzLyricsDocument};
use crate::lrc::{emit_lrc, LrcSpan};
use crate::model::{LyricsDoc, LyricsLine, LyricsProvider, Word};
/// Discriminator tag — always `"wsync"` on output; accepts `"lsync"` on input
/// (the yaml's guess, kept as alias like the wire DTO does) and defaults when
/// absent (stored docs are self-describing but tolerant).
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum WsyncTag {
#[default]
#[serde(rename = "wsync", alias = "lsync")]
Wsync,
}
/// Native word-synced lyrics document (cache column `qobuz_wsync_json`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QobuzWsync {
#[serde(rename = "type", default)]
pub tag: WsyncTag,
#[serde(default)]
pub lang: Option<String>,
#[serde(default)]
pub lines: Vec<QobuzWsyncLine>,
/// Wrapper metadata passthrough (additive; absent on wire content unions).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub translation_langs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub writers: Option<String>,
}
/// One synced line, wire-shaped. Gap separator lines come as
/// `{"line":"","words":[]}` with no `start`/`end` (live divergence — see
/// `qbz-qobuz/src/lyrics.rs`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QobuzWsyncLine {
#[serde(default)]
pub line: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub words: Vec<QobuzWsyncWord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end: Option<i64>,
}
/// One word stamp, wire-shaped (`word`/`start`/`end`, ms from track start).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct QobuzWsyncWord {
#[serde(default)]
pub word: String,
#[serde(default)]
pub start: i64,
#[serde(default)]
pub end: i64,
}
impl QobuzWsync {
/// Build from a fetched Qobuz document. `None` unless the document's
/// content is the synced variant. ALL lines are preserved verbatim
/// (including empty gap separators) — this is the native form.
pub fn from_document(doc: &QobuzLyricsDocument) -> Option<Self> {
match doc.original.as_ref()? {
QobuzLyricsContent::Synced { lang, lines } => Some(Self {
tag: WsyncTag::Wsync,
lang: lang.clone(),
lines: lines
.iter()
.map(|line| QobuzWsyncLine {
line: line.line.clone(),
words: line
.words
.iter()
.map(|word| QobuzWsyncWord {
word: word.word.clone(),
start: word.start,
end: word.end,
})
.collect(),
start: line.start,
end: line.end,
})
.collect(),
translation_langs: doc.translation_langs.clone(),
writers: doc.writers.clone(),
}),
QobuzLyricsContent::Plain { .. } => None,
}
}
/// Convert to the domain model. Gap separator lines (empty text) and
/// unplaceable lines (text without a `start` stamp) are skipped — in
/// wsync the previous line already carries its explicit `end`, so nothing
/// is lost. Native word stamps are preserved.
pub fn to_doc(&self) -> LyricsDoc {
let lines: Vec<LyricsLine> = self
.lines
.iter()
.filter(|line| !line.line.trim().is_empty())
.filter_map(|line| {
let start = line.start?;
Some(LyricsLine {
time_ms: Some(start),
end_ms: line.end,
text: line.line.clone(),
words: if line.words.is_empty() {
None
} else {
Some(
line.words
.iter()
.map(|word| Word {
start: word.start,
end: word.end,
text: word.word.clone(),
})
.collect(),
)
},
})
})
.collect();
LyricsDoc {
synced: !lines.is_empty(),
lines,
provider: LyricsProvider::Qobuz,
translation_langs: self.translation_langs.clone(),
writers: self.writers.clone(),
}
}
/// Emit the cross-frontend LRC form (gap markers carry end-of-vocal;
/// lossy at word level only — documented Q5 trade-off).
pub fn to_lrc(&self) -> String {
let spans: Vec<LrcSpan> = self
.lines
.iter()
.filter(|line| !line.line.trim().is_empty())
.filter_map(|line| {
let start_ms = line.start?;
Some(LrcSpan {
start_ms,
end_ms: line.end,
text: line.line.clone(),
})
})
.collect();
emit_lrc(&spans)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_document() -> QobuzLyricsDocument {
// Shape mirrors the live capture lyrics-doc-wsync.json (LUNCH /
// Billie Eilish): regular lines with word stamps + a gap separator
// line without stamps.
let json = r#"{
"album_id": "gvcirtodd95kc",
"track_id": 266725027,
"translation_langs": ["es", "fr"],
"publishers": [{"copyright": "(c) Example", "zones": ["WW"]}],
"writers": "Billie Eilish O'Connell",
"original": {
"type": "wsync",
"lang": "en",
"lines": [
{"line": "Oh, mm-mm", "start": 1750, "end": 4770,
"words": [
{"word": "Oh,", "start": 1750, "end": 2480},
{"word": "mm-mm", "start": 2480, "end": 4770}
]},
{"line": "", "words": []},
{"line": "I could eat that girl for lunch", "start": 19690, "end": 22180,
"words": [
{"word": "I", "start": 19690, "end": 19890},
{"word": "could", "start": 19890, "end": 20180},
{"word": "eat", "start": 20180, "end": 20560},
{"word": "that", "start": 20560, "end": 20850},
{"word": "girl", "start": 20850, "end": 21310},
{"word": "for", "start": 21310, "end": 21600},
{"word": "lunch", "start": 21600, "end": 22180}
]}
]
}
}"#;
serde_json::from_str(json).expect("valid document fixture")
}
#[test]
fn from_document_preserves_lines_words_and_metadata() {
let wsync = QobuzWsync::from_document(&sample_document()).expect("synced doc");
assert_eq!(wsync.lang.as_deref(), Some("en"));
assert_eq!(wsync.lines.len(), 3); // gap separator preserved natively
assert_eq!(wsync.lines[0].words.len(), 2);
assert_eq!(wsync.translation_langs, vec!["es", "fr"]);
assert_eq!(wsync.writers.as_deref(), Some("Billie Eilish O'Connell"));
}
#[test]
fn from_document_is_none_for_plain() {
let json = r#"{
"track_id": 29006863,
"original": {"type": "plain", "lang": "en", "lines": [{"line": "Night"}]}
}"#;
let doc: QobuzLyricsDocument = serde_json::from_str(json).unwrap();
assert!(QobuzWsync::from_document(&doc).is_none());
}
#[test]
fn to_doc_skips_gaps_and_preserves_words() {
let wsync = QobuzWsync::from_document(&sample_document()).unwrap();
let doc = wsync.to_doc();
assert!(doc.synced);
assert_eq!(doc.provider, LyricsProvider::Qobuz);
assert_eq!(doc.lines.len(), 2); // gap separator dropped from render model
assert_eq!(doc.lines[0].time_ms, Some(1750));
assert_eq!(doc.lines[0].end_ms, Some(4770));
let words = doc.lines[1].words.as_ref().expect("words preserved");
assert_eq!(words.len(), 7);
assert_eq!(words[6].text, "lunch");
assert_eq!(words[6].start, 21_600);
assert_eq!(doc.translation_langs, vec!["es", "fr"]);
assert_eq!(doc.writers.as_deref(), Some("Billie Eilish O'Connell"));
}
#[test]
fn to_lrc_emits_gap_markers_from_native_ends() {
let wsync = QobuzWsync::from_document(&sample_document()).unwrap();
let lrc = wsync.to_lrc();
// Vocal ends at 4770, next line starts at 19690 -> gap marker.
assert!(lrc.contains("[00:01.750] Oh, mm-mm"));
assert!(lrc.contains("[00:04.770]\n"));
assert!(lrc.contains("[00:19.690] I could eat that girl for lunch"));
// wsync -> model -> LRC -> parse: bounds recoverable by the parser.
let parsed = crate::lrc::parse_lrc(&lrc);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].end_ms, Some(4770));
assert_eq!(parsed[1].end_ms, Some(22_180));
// …and word stamps are gone in the LRC form (documented lossiness).
assert!(parsed.iter().all(|line| line.words.is_none()));
}
#[test]
fn stored_json_round_trips_and_stays_wire_shaped() {
let wsync = QobuzWsync::from_document(&sample_document()).unwrap();
let json = serde_json::to_string(&wsync).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["type"], "wsync");
assert_eq!(value["lines"][0]["words"][0]["word"], "Oh,");
let back: QobuzWsync = serde_json::from_str(&json).unwrap();
assert_eq!(back, wsync);
// A bare wire content union (no metadata, as fetched from the CDN)
// parses too.
let wire = r#"{"type":"wsync","lang":"en","lines":[{"line":"hi","start":1,"end":2,
"words":[{"word":"hi","start":1,"end":2}]}]}"#;
let parsed: QobuzWsync = serde_json::from_str(wire).unwrap();
assert_eq!(parsed.lines.len(), 1);
assert!(parsed.translation_langs.is_empty());
// The yaml-era "lsync" tag is tolerated on input.
let lsync = r#"{"type":"lsync","lines":[{"line":"hi","start":1,"end":2}]}"#;
let parsed: QobuzWsync = serde_json::from_str(lsync).unwrap();
assert_eq!(parsed.lines[0].start, Some(1));
}
}