Fix incomplete MPD cover transfers, add cover revalidation, and allow retrying missing MPD genres
This commit is contained in:
@@ -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 <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
|
||||
|
||||
239
src/db.rs
239
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<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>> {
|
||||
// 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<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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
28
src/main.rs
28
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user