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
|
--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
239
src/db.rs
@@ -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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
src/main.rs
28
src/main.rs
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user