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:
10 files changed
+2925
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user