From 02ff0b4c973c5e9b7a21e2a43c26e5afd77e3699 Mon Sep 17 00:00:00 2001 From: Artur Meski Date: Mon, 23 Mar 2026 22:33:45 +0000 Subject: [PATCH] Fix incomplete MPD cover transfers, add cover revalidation, and allow retrying missing MPD genres --- README.md | 9 ++ src/db.rs | 239 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/enrich.rs | 5 +- src/main.rs | 28 ++++++ src/report.rs | 6 ++ 5 files changed, 286 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4f40b21..a9653de 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ scrbblr enrich [OPTIONS] --online Fetch metadata and covers from MusicBrainz / iTunes / CAA --force Re-fetch all albums from MusicBrainz (implies --online) --retry-covers Reset the 7-day cooldown for albums missing covers + --retry-mpd-genres Reset the 7-day cooldown for MPD albums missing genres (requires --online) --artist Limit enrichment to one artist (case-insensitive) --no-itunes Skip iTunes; fall back directly to Cover Art Archive --no-mpd-covers Skip MPD embedded cover extraction @@ -336,6 +337,14 @@ Automatic enrichment (triggered by `report --html`) uses a 7-day retry cooldown scrbblr enrich --online --retry-covers ``` +If you specifically want to retry **missing genres for MPD-sourced albums only**: + +```bash +scrbblr enrich --online --retry-mpd-genres +``` + +You can combine this with `--artist` to target one artist. + Use `--force` to re-fetch all albums from scratch (ignores the cooldown entirely): ```bash diff --git a/src/db.rs b/src/db.rs index 35f8401..62284fa 100644 --- a/src/db.rs +++ b/src/db.rs @@ -762,6 +762,49 @@ pub fn reset_missing_cover_timestamps_for_artist(conn: &Connection, artist: &str Ok(count) } +/// Reset `fetched_at` to NULL for MPD-sourced albums that have no genre yet. +/// +/// This allows online enrichment to re-try genre lookups immediately for +/// MPD albums, bypassing the 7-day cooldown used by [`uncached_albums`]. +pub fn reset_missing_genre_timestamps_for_mpd(conn: &Connection) -> Result { + let count = conn.execute( + "UPDATE album_cache + SET fetched_at = NULL + WHERE genre IS NULL + AND EXISTS ( + SELECT 1 FROM scrobbles s + WHERE s.artist = album_cache.artist + AND s.album = album_cache.album + AND s.source = 'MPD' + )", + [], + )?; + Ok(count) +} + +/// Reset `fetched_at` to NULL for one artist's MPD-sourced genre-missing albums. +/// +/// Matching is case-insensitive (`LOWER(artist) = LOWER(?1)`). +pub fn reset_missing_genre_timestamps_for_mpd_artist( + conn: &Connection, + artist: &str, +) -> Result { + let count = conn.execute( + "UPDATE album_cache + SET fetched_at = NULL + WHERE genre IS NULL + AND LOWER(artist) = LOWER(?1) + AND EXISTS ( + SELECT 1 FROM scrobbles s + WHERE s.artist = album_cache.artist + AND s.album = album_cache.album + AND s.source = 'MPD' + )", + params![artist], + )?; + Ok(count) +} + pub fn uncached_albums(conn: &Connection) -> Result> { // Retry incomplete cache entries at most once per 7 days. let retry_before = (chrono::Local::now().naive_local() - chrono::Duration::days(7)) @@ -2108,4 +2151,200 @@ mod tests { let expected = today_at("12:01:00"); assert_eq!(deftones_fetched.as_deref(), Some(expected.as_str())); } + + #[test] + fn test_reset_missing_genre_timestamps_for_mpd_only() { + let conn = open_memory_db().unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + track_duration_secs: Some(291), + played_duration_secs: 200, + scrobbled_at: today_at("10:00:00"), + source: "MPD".into(), + }, + ) + .unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "Bonobo".into(), + album: "Black Sands".into(), + title: "Kiara".into(), + track_duration_secs: Some(220), + played_duration_secs: 200, + scrobbled_at: today_at("10:01:00"), + source: "Qobuz".into(), + }, + ) + .unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "Tourist".into(), + album: "Inside Out".into(), + title: "Inside Out".into(), + track_duration_secs: Some(240), + played_duration_secs: 200, + scrobbled_at: today_at("10:02:00"), + source: "MPD".into(), + }, + ) + .unwrap(); + + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Deftones".into(), + album: "White Pony".into(), + musicbrainz_id: Some("mbid-1".into()), + cover_url: Some("covers/mpd_x.jpg".into()), + genre: None, + fetched_at: today_at("12:00:00"), + }, + ) + .unwrap(); + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Bonobo".into(), + album: "Black Sands".into(), + musicbrainz_id: Some("mbid-2".into()), + cover_url: Some("covers/itunes_x.jpg".into()), + genre: None, + fetched_at: today_at("12:01:00"), + }, + ) + .unwrap(); + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Tourist".into(), + album: "Inside Out".into(), + musicbrainz_id: Some("mbid-3".into()), + cover_url: Some("covers/mpd_y.jpg".into()), + genre: Some("downtempo".into()), + fetched_at: today_at("12:02:00"), + }, + ) + .unwrap(); + + let updated = reset_missing_genre_timestamps_for_mpd(&conn).unwrap(); + assert_eq!(updated, 1); + + let deftones_fetched: Option = conn + .query_row( + "SELECT fetched_at FROM album_cache WHERE artist='Deftones' AND album='White Pony'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(deftones_fetched.is_none()); + + let bonobo_fetched: Option = conn + .query_row( + "SELECT fetched_at FROM album_cache WHERE artist='Bonobo' AND album='Black Sands'", + [], + |r| r.get(0), + ) + .unwrap(); + let bonobo_expected = today_at("12:01:00"); + assert_eq!(bonobo_fetched.as_deref(), Some(bonobo_expected.as_str())); + + let tourist_fetched: Option = conn + .query_row( + "SELECT fetched_at FROM album_cache WHERE artist='Tourist' AND album='Inside Out'", + [], + |r| r.get(0), + ) + .unwrap(); + let tourist_expected = today_at("12:02:00"); + assert_eq!(tourist_fetched.as_deref(), Some(tourist_expected.as_str())); + } + + #[test] + fn test_reset_missing_genre_timestamps_for_mpd_artist_only() { + let conn = open_memory_db().unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "Pink Floyd".into(), + album: "The Wall".into(), + title: "Mother".into(), + track_duration_secs: Some(345), + played_duration_secs: 200, + scrobbled_at: today_at("10:00:00"), + source: "MPD".into(), + }, + ) + .unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + track_duration_secs: Some(291), + played_duration_secs: 200, + scrobbled_at: today_at("10:01:00"), + source: "MPD".into(), + }, + ) + .unwrap(); + + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Pink Floyd".into(), + album: "The Wall".into(), + musicbrainz_id: Some("mbid-1".into()), + cover_url: Some("covers/mpd_1.jpg".into()), + genre: None, + fetched_at: today_at("12:00:00"), + }, + ) + .unwrap(); + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Deftones".into(), + album: "White Pony".into(), + musicbrainz_id: Some("mbid-2".into()), + cover_url: Some("covers/mpd_2.jpg".into()), + genre: None, + fetched_at: today_at("12:01:00"), + }, + ) + .unwrap(); + + let updated = reset_missing_genre_timestamps_for_mpd_artist(&conn, "pink floyd").unwrap(); + assert_eq!(updated, 1); + + let pink_fetched: Option = conn + .query_row( + "SELECT fetched_at FROM album_cache WHERE artist='Pink Floyd' AND album='The Wall'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(pink_fetched.is_none()); + + let deftones_fetched: Option = conn + .query_row( + "SELECT fetched_at FROM album_cache WHERE artist='Deftones' AND album='White Pony'", + [], + |r| r.get(0), + ) + .unwrap(); + let expected = today_at("12:01:00"); + assert_eq!(deftones_fetched.as_deref(), Some(expected.as_str())); + } } diff --git a/src/enrich.rs b/src/enrich.rs index b36d1d8..474fb6f 100644 --- a/src/enrich.rs +++ b/src/enrich.rs @@ -1223,7 +1223,10 @@ pub fn enrich_by_mbid( // Reject MBIDs that contain path-traversal sequences or directory // separators — a valid UUID contains only hex digits and hyphens. if mbid.contains("..") || mbid.contains('/') || mbid.contains('\\') { - eprintln!("[error] Invalid MBID '{}': must not contain path components.", mbid); + eprintln!( + "[error] Invalid MBID '{}': must not contain path components.", + mbid + ); return; } diff --git a/src/main.rs b/src/main.rs index d183fb5..85b2fca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -191,6 +191,13 @@ enum Commands { #[arg(long)] retry_covers: bool, + /// Re-attempt genre lookups for MPD-sourced albums that still have no + /// genre. Resets the 7-day cooldown for those albums only, so the next + /// `--online` run retries them. Does not affect albums that already + /// have a genre. + #[arg(long)] + retry_mpd_genres: bool, + /// Limit enrichment to albums by this artist (case-insensitive match). /// /// Useful when fixing one artist's covers/metadata without touching @@ -751,6 +758,7 @@ fn main() { online, force, retry_covers, + retry_mpd_genres, artist, no_itunes, no_mpd_covers, @@ -758,6 +766,11 @@ fn main() { mpd_port, db_path, } => { + if retry_mpd_genres && !online { + eprintln!("--retry-mpd-genres requires --online."); + std::process::exit(1); + } + if no_mpd_covers && !online { eprintln!("Nothing to do. Pass --online and/or omit --no-mpd-covers."); eprintln!( @@ -835,6 +848,21 @@ fn main() { } } + if retry_mpd_genres { + let reset = if let Some(ref artist_name) = artist { + db::reset_missing_genre_timestamps_for_mpd_artist(&conn, artist_name) + } else { + db::reset_missing_genre_timestamps_for_mpd(&conn) + }; + + match reset { + Ok(n) => { + eprintln!("Reset cooldown for {} MPD album(s) missing genres.", n) + } + Err(e) => eprintln!("[warn] Failed to reset MPD genre timestamps: {}", e), + } + } + if online { if let Some(ref needed) = artist_needed { enrich::run_enrich_targeted_with_options( diff --git a/src/report.rs b/src/report.rs index 57ad6e3..95d96a9 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1243,6 +1243,12 @@ h1, h2, h3 { margin: 0; } border-radius: 14px; box-shadow: 0 14px 40px rgba(0,0,0,.25); } .sub { margin-top: 6px; color: var(--muted); font-size: 14px; } +.sub a { + color: var(--bar-fill); + text-decoration-color: #a87937; + text-underline-offset: 2px; +} +.sub a:hover { color: #ffd38e; } .jump-nav { position: sticky; top: 0; z-index: 20; margin-top: 12px; padding: 8px;