Fix incomplete MPD cover transfers, add cover revalidation, and allow retrying missing MPD genres

This commit is contained in:
2026-03-23 22:33:45 +00:00
parent 6e0432490a
commit 02ff0b4c97
5 changed files with 286 additions and 1 deletions

View File

@@ -271,6 +271,7 @@ scrbblr enrich [OPTIONS]
--online Fetch metadata and covers from MusicBrainz / iTunes / CAA --online Fetch metadata and covers from MusicBrainz / iTunes / CAA
--force Re-fetch all albums from MusicBrainz (implies --online) --force Re-fetch all albums from MusicBrainz (implies --online)
--retry-covers Reset the 7-day cooldown for albums missing covers --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 <ARTIST> Limit enrichment to one artist (case-insensitive) --artist <ARTIST> Limit enrichment to one artist (case-insensitive)
--no-itunes Skip iTunes; fall back directly to Cover Art Archive --no-itunes Skip iTunes; fall back directly to Cover Art Archive
--no-mpd-covers Skip MPD embedded cover extraction --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 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): Use `--force` to re-fetch all albums from scratch (ignores the cooldown entirely):
```bash ```bash

239
src/db.rs
View File

@@ -762,6 +762,49 @@ pub fn reset_missing_cover_timestamps_for_artist(conn: &Connection, artist: &str
Ok(count) 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<usize> {
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<usize> {
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<Vec<UncachedAlbum>> { pub fn uncached_albums(conn: &Connection) -> Result<Vec<UncachedAlbum>> {
// Retry incomplete cache entries at most once per 7 days. // Retry incomplete cache entries at most once per 7 days.
let retry_before = (chrono::Local::now().naive_local() - chrono::Duration::days(7)) 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"); let expected = today_at("12:01:00");
assert_eq!(deftones_fetched.as_deref(), Some(expected.as_str())); 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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()));
}
} }

View File

@@ -1223,7 +1223,10 @@ pub fn enrich_by_mbid(
// Reject MBIDs that contain path-traversal sequences or directory // Reject MBIDs that contain path-traversal sequences or directory
// separators — a valid UUID contains only hex digits and hyphens. // separators — a valid UUID contains only hex digits and hyphens.
if mbid.contains("..") || mbid.contains('/') || mbid.contains('\\') { 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; return;
} }

View File

@@ -191,6 +191,13 @@ enum Commands {
#[arg(long)] #[arg(long)]
retry_covers: bool, 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). /// Limit enrichment to albums by this artist (case-insensitive match).
/// ///
/// Useful when fixing one artist's covers/metadata without touching /// Useful when fixing one artist's covers/metadata without touching
@@ -751,6 +758,7 @@ fn main() {
online, online,
force, force,
retry_covers, retry_covers,
retry_mpd_genres,
artist, artist,
no_itunes, no_itunes,
no_mpd_covers, no_mpd_covers,
@@ -758,6 +766,11 @@ fn main() {
mpd_port, mpd_port,
db_path, db_path,
} => { } => {
if retry_mpd_genres && !online {
eprintln!("--retry-mpd-genres requires --online.");
std::process::exit(1);
}
if no_mpd_covers && !online { if no_mpd_covers && !online {
eprintln!("Nothing to do. Pass --online and/or omit --no-mpd-covers."); eprintln!("Nothing to do. Pass --online and/or omit --no-mpd-covers.");
eprintln!( 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 online {
if let Some(ref needed) = artist_needed { if let Some(ref needed) = artist_needed {
enrich::run_enrich_targeted_with_options( enrich::run_enrich_targeted_with_options(

View File

@@ -1243,6 +1243,12 @@ h1, h2, h3 { margin: 0; }
border-radius: 14px; box-shadow: 0 14px 40px rgba(0,0,0,.25); border-radius: 14px; box-shadow: 0 14px 40px rgba(0,0,0,.25);
} }
.sub { margin-top: 6px; color: var(--muted); font-size: 14px; } .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 { .jump-nav {
position: sticky; top: 0; z-index: 20; position: sticky; top: 0; z-index: 20;
margin-top: 12px; padding: 8px; margin-top: 12px; padding: 8px;