Refactor enrich CLI into pipeline, fetch, and retry modes

This commit is contained in:
2026-03-23 22:52:01 +00:00
parent 02ff0b4c97
commit 275449d6d7
4 changed files with 454 additions and 140 deletions

View File

@@ -13,8 +13,8 @@
//! 2. For each, searches MusicBrainz for matching releases (to get MBID and
//! genre/tag data — always done regardless of cover source).
//! 3. Tries iTunes Search API first for cover art (fast, no rate limit,
//! good commercial coverage). Pass `--no-itunes` to skip.
//! 4. If iTunes has no cover (or `--no-itunes`), falls back to the Cover Art
//! good commercial coverage). Can be switched to CAA-only mode.
//! 4. If iTunes has no cover (or iTunes is disabled), falls back to the Cover Art
//! Archive using the MBID from step 2:
//! a. The specific release endpoint
//! b. The release-group endpoint (covers any edition of the album)
@@ -97,6 +97,52 @@ const MAX_COVER_DIM: u32 = 500;
/// JPEG quality used when re-encoding resized cover art (0100).
const COVER_JPEG_QUALITY: u8 = 85;
/// What the online enrichment stage should fetch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnlineFetchMode {
/// Fetch genres and covers.
All,
/// Fetch covers only.
Covers,
/// Fetch genres only.
Genres,
}
impl OnlineFetchMode {
fn wants_covers(self) -> bool {
matches!(self, Self::All | Self::Covers)
}
fn wants_genres(self) -> bool {
matches!(self, Self::All | Self::Genres)
}
}
/// Cover source policy for the online stage.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoverSourceMode {
/// Try iTunes first, then fall back to Cover Art Archive.
ItunesThenCaa,
/// Skip iTunes and use only Cover Art Archive.
CaaOnly,
}
/// Options controlling online enrichment behaviour.
#[derive(Debug, Clone, Copy)]
pub struct OnlineEnrichOptions {
pub fetch_mode: OnlineFetchMode,
pub cover_source: CoverSourceMode,
}
impl Default for OnlineEnrichOptions {
fn default() -> Self {
Self {
fetch_mode: OnlineFetchMode::All,
cover_source: CoverSourceMode::ItunesThenCaa,
}
}
}
// ---------------------------------------------------------------------------
// MusicBrainz API response types (only the fields we need)
// ---------------------------------------------------------------------------
@@ -959,9 +1005,8 @@ pub fn run_enrich_targeted(
conn: &Connection,
needed: &std::collections::HashSet<(String, String)>,
quiet: bool,
no_itunes: bool,
) {
run_enrich_targeted_with_options(conn, needed, quiet, no_itunes, false);
run_enrich_targeted_with_options(conn, needed, quiet, false, OnlineEnrichOptions::default());
}
/// Like [`run_enrich_targeted`], but supports force refresh for the provided
@@ -970,8 +1015,8 @@ pub fn run_enrich_targeted_with_options(
conn: &Connection,
needed: &std::collections::HashSet<(String, String)>,
quiet: bool,
no_itunes: bool,
force: bool,
options: OnlineEnrichOptions,
) {
if needed.is_empty() {
if !quiet {
@@ -993,7 +1038,7 @@ pub fn run_enrich_targeted_with_options(
"Force mode enabled: refreshing {} selected album(s).",
albums.len()
);
run_enrich_albums(conn, albums, quiet, no_itunes, true);
run_enrich_albums(conn, albums, quiet, true, options);
return;
}
@@ -1009,7 +1054,7 @@ pub fn run_enrich_targeted_with_options(
.into_iter()
.filter(|a| needed.contains(&(a.artist.clone(), a.album.clone())))
.collect();
run_enrich_albums(conn, albums, quiet, no_itunes, false);
run_enrich_albums(conn, albums, quiet, false, options);
}
/// Run the enrichment process: find all uncached albums and fetch their
@@ -1023,8 +1068,13 @@ pub fn run_enrich_targeted_with_options(
/// - `force` — if true, re-fetch all albums even if already cached
/// - `quiet` — if true, suppress the "nothing to do" hint (used when called
/// from `report --html` where the user didn't explicitly ask for enrichment)
/// - `no_itunes` — if true, skip the iTunes cover lookup and go straight to CAA
pub fn run_enrich(conn: &Connection, force: bool, quiet: bool, no_itunes: bool) {
/// Run online enrichment with explicit fetch/source options.
pub fn run_enrich_with_options(
conn: &Connection,
force: bool,
quiet: bool,
options: OnlineEnrichOptions,
) {
if force {
conn.execute("DELETE FROM album_cache", [])
.expect("Failed to clear album_cache");
@@ -1032,7 +1082,7 @@ pub fn run_enrich(conn: &Connection, force: bool, quiet: bool, no_itunes: bool)
eprintln!("Force mode enabled: refreshing existing cover files when re-fetching.");
}
let albums = db::uncached_albums(conn).expect("Failed to query uncached albums");
run_enrich_albums(conn, albums, quiet, no_itunes, force);
run_enrich_albums(conn, albums, quiet, force, options);
}
/// Inner enrichment loop shared by `run_enrich` and `run_enrich_targeted`.
@@ -1040,8 +1090,8 @@ fn run_enrich_albums(
conn: &Connection,
albums: Vec<db::UncachedAlbum>,
quiet: bool,
no_itunes: bool,
force_redownload: bool,
options: OnlineEnrichOptions,
) {
if albums.is_empty() {
if !quiet {
@@ -1053,11 +1103,15 @@ fn run_enrich_albums(
let client = build_client();
let covers = covers_dir();
let fetch_covers = options.fetch_mode.wants_covers();
let fetch_genres = options.fetch_mode.wants_genres();
let use_itunes = fetch_covers && matches!(options.cover_source, CoverSourceMode::ItunesThenCaa);
eprintln!("Found {} album(s) to enrich.", albums.len());
let mut success_count = 0;
let mut cover_count = 0;
let mut genre_count = 0;
for (i, album) in albums.iter().enumerate() {
eprintln!(
@@ -1068,6 +1122,12 @@ fn run_enrich_albums(
album.album
);
let existing = db::album_cache_meta(conn, &album.artist, &album.album)
.ok()
.flatten();
let existing_genre = existing.as_ref().and_then(|m| m.genre.clone());
let existing_mbid = existing.as_ref().and_then(|m| m.mbid.clone());
// Step 1: Search MusicBrainz for matching releases.
// Always done — we need the MBID for genre data and as the CAA
// lookup key, even if we end up getting the cover from iTunes.
@@ -1079,7 +1139,9 @@ fn run_enrich_albums(
// Without an MBID we can't query CAA, so iTunes is the only
// cover option. Use a hash-based filename since there's no MBID.
let cover = if no_itunes {
let cover = if !fetch_covers {
None
} else if !use_itunes {
eprintln!(" iTunes disabled, no cover available.");
None
} else {
@@ -1094,7 +1156,7 @@ fn run_enrich_albums(
if cover.is_some() {
eprintln!(" Cover downloaded (from iTunes).");
cover_count += 1;
} else if !no_itunes {
} else if fetch_covers && use_itunes {
eprintln!(" No cover art available from any source.");
}
@@ -1102,9 +1164,9 @@ fn run_enrich_albums(
let entry = AlbumCacheEntry {
artist: album.artist.clone(),
album: album.album.clone(),
musicbrainz_id: None,
musicbrainz_id: existing_mbid,
cover_url: cover,
genre: None,
genre: existing_genre,
fetched_at: now_str(),
};
match db::upsert_album_cache(conn, &entry) {
@@ -1119,16 +1181,26 @@ fn run_enrich_albums(
eprintln!(" Found MBID: {}", primary_mbid);
// Step 2: Fetch genres/tags and release-group ID from the primary release.
thread::sleep(RATE_LIMIT_DELAY);
let (genre, release_group_id) = fetch_release_details(&client, primary_mbid);
if let Some(ref g) = genre {
let (fetched_genre, release_group_id) = if fetch_genres || fetch_covers {
thread::sleep(RATE_LIMIT_DELAY);
fetch_release_details(&client, primary_mbid)
} else {
(None, None)
};
if let Some(ref g) = fetched_genre {
if fetch_genres {
genre_count += 1;
}
eprintln!(" Genres: {}", g);
}
// Step 3: Try iTunes first — it's fast, has no rate limit, and has
// good commercial coverage. Reuse the MBID-based filename so the file
// is still associated with the correct release regardless of source.
let mut cover = if no_itunes {
let mut cover = if !fetch_covers {
None
} else if !use_itunes {
None
} else {
let itunes_cover = fetch_itunes_cover_url(&client, &album.artist, &album.album)
@@ -1145,8 +1217,8 @@ fn run_enrich_albums(
// Step 4: If iTunes had no cover, fall back to the Cover Art Archive.
// CAA is better for obscure or non-commercial releases, and has the
// highest possible resolution for releases it does have.
if cover.is_none() {
if !no_itunes {
if fetch_covers && cover.is_none() {
if use_itunes {
eprintln!(" No iTunes cover. Trying Cover Art Archive...");
}
@@ -1181,17 +1253,23 @@ fn run_enrich_albums(
if cover.is_some() {
cover_count += 1;
} else {
} else if fetch_covers {
eprintln!(" No cover art available from any source.");
}
let genre_to_store = if fetch_genres {
fetched_genre.or(existing_genre)
} else {
existing_genre
};
// Step 5: Store the result in album_cache.
let entry = AlbumCacheEntry {
artist: album.artist.clone(),
album: album.album.clone(),
musicbrainz_id: Some(primary_mbid.clone()),
cover_url: cover,
genre,
genre: genre_to_store,
fetched_at: now_str(),
};
@@ -1205,7 +1283,12 @@ fn run_enrich_albums(
eprintln!("Enrichment complete:");
eprintln!(" Albums processed: {}", albums.len());
eprintln!(" Successfully cached: {}", success_count);
eprintln!(" Covers downloaded: {}", cover_count);
if fetch_covers {
eprintln!(" Covers downloaded: {}", cover_count);
}
if fetch_genres {
eprintln!(" Albums with genres fetched: {}", genre_count);
}
eprintln!(" Covers directory: {}", covers.display());
}