diff --git a/src-tauri/crates/app/src/commands/lyrics.rs b/src-tauri/crates/app/src/commands/lyrics.rs index 32da7d40..5e07c127 100644 --- a/src-tauri/crates/app/src/commands/lyrics.rs +++ b/src-tauri/crates/app/src/commands/lyrics.rs @@ -118,6 +118,19 @@ pub struct LyricsPayload { pub content: String, pub format: LyricsFormat, pub source: LyricsSource, + /// Sub-provider that produced this row when `source` is + /// `LyricsSource::Api`. Matches `Provider::as_str()` from + /// `waveflow_syncedlyrics` (snake_case identifier: + /// `"lrclib"` / `"genius"` / `"net_ease"` / `"megalobiz"` / + /// `"musixmatch"`). `None` for embedded / sidecar / manual rows + /// and for pre-1.5.1 cached entries that pre-date the + /// `lyrics.provider` column. The UI surfaces this in the source + /// badge so the user knows whether they're looking at LRCLIB- + /// curated lyrics or a Genius scrape — important when the latter + /// occasionally returns junk (issue #284) and the user wants to + /// re-fetch from a different provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, /// Set by `save_lyrics` when destination = `tag` was requested but /// the audio file's tag system can't carry the chosen format (e.g. /// TTML in an MP3's ID3v2 where lofty has no mapping for the @@ -413,12 +426,22 @@ async fn cache_external_lyrics( ) -> AppResult { let format = external_format_to_app(result.format); let source = LyricsSource::Api; - upsert_lyrics(pool, file_hash, &result.content, &format, &source).await?; + let provider = result.provider.as_str(); + upsert_lyrics( + pool, + file_hash, + &result.content, + &format, + &source, + Some(provider), + ) + .await?; Ok(LyricsPayload { track_id, content: result.content, format, source, + provider: Some(provider.to_string()), tag_write_skipped: None, sidecar_write_skipped: None, }) @@ -462,7 +485,9 @@ async fn try_external_fallback_or_miss( // No provider had lyrics. Cache as an empty row so we don't re-hit // the network on every panel open. The user can force a re-search // by clicking "Refetch" in the lyrics panel (clears the row, - // re-runs the waterfall). + // re-runs the waterfall). `provider = None` for the miss row — + // attribution to a specific provider would be misleading when + // every provider returned nothing. let empty = String::new(); upsert_lyrics( pool, @@ -470,6 +495,7 @@ async fn try_external_fallback_or_miss( &empty, &LyricsFormat::Plain, &LyricsSource::Api, + None, ) .await?; Ok(LyricsPayload { @@ -477,6 +503,7 @@ async fn try_external_fallback_or_miss( content: empty, format: LyricsFormat::Plain, source: LyricsSource::Api, + provider: None, tag_write_skipped: None, sidecar_write_skipped: None, }) @@ -679,26 +706,35 @@ fn read_non_empty_file(path: &Path) -> Option { /// Insert (or replace) the lyrics row, keyed by file content hash so the /// cache is shared across profiles that contain the same audio file. +/// +/// `provider` carries the sub-source identifier when `source` is +/// `LyricsSource::Api` (e.g. `"lrclib"`, `"genius"`) and `None` for +/// embedded / sidecar / manual writes where the broad `source` is +/// the only meaningful attribution. The DB column allows NULL so +/// pre-1.5.1 rows + non-API tiers store cleanly. async fn upsert_lyrics( pool: &sqlx::SqlitePool, file_hash: &str, content: &str, format: &LyricsFormat, source: &LyricsSource, + provider: Option<&str>, ) -> AppResult<()> { sqlx::query( - "INSERT INTO app.lyrics (file_hash, content, format, source, language, fetched_at) - VALUES (?, ?, ?, ?, NULL, ?) + "INSERT INTO app.lyrics (file_hash, content, format, source, provider, language, fetched_at) + VALUES (?, ?, ?, ?, ?, NULL, ?) ON CONFLICT(file_hash) DO UPDATE SET content = excluded.content, format = excluded.format, source = excluded.source, + provider = excluded.provider, fetched_at = excluded.fetched_at", ) .bind(file_hash) .bind(content) .bind(format_to_db(format)) .bind(source_to_db(source)) + .bind(provider) .bind(now_ms()) .execute(pool) .await?; @@ -709,8 +745,8 @@ async fn upsert_lyrics( /// numeric `track_id` so we look up the file hash first, then key into the /// shared `app.lyrics` cache. async fn read_cached(pool: &sqlx::SqlitePool, track_id: i64) -> AppResult> { - let row: Option<(String, String, String)> = sqlx::query_as( - "SELECT l.content, l.format, l.source + let row: Option<(String, String, String, Option)> = sqlx::query_as( + "SELECT l.content, l.format, l.source, l.provider FROM track t JOIN app.lyrics l ON l.file_hash = t.file_hash WHERE t.id = ?", @@ -718,11 +754,12 @@ async fn read_cached(pool: &sqlx::SqlitePool, track_id: i64) -> AppResult