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

@@ -268,15 +268,14 @@ scrbblr report [OPTIONS]
--db-path <PATH> Path to the SQLite database --db-path <PATH> Path to the SQLite database
scrbblr enrich [OPTIONS] scrbblr enrich [OPTIONS]
--online Fetch metadata and covers from MusicBrainz / iTunes / CAA --pipeline <MODE> Enrichment stage: mpd, online, or both [default: both]
--force Re-fetch all albums from MusicBrainz (implies --online) --fetch <WHAT> Online payload: all, covers, or genres [default: all]
--retry-covers Reset the 7-day cooldown for albums missing covers --retry <MODE> Retry selector: none, covers, genres, all, mpd-genres [default: none]
--retry-mpd-genres Reset the 7-day cooldown for MPD albums missing genres (requires --online) --cover-source <SRC> Cover source policy: itunes-caa or caa [default: itunes-caa]
--force Re-fetch all albums from MusicBrainz (online pipeline only)
--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 --mpd-host <HOST> MPD host for MPD pipeline [default: localhost]
--no-mpd-covers Skip MPD embedded cover extraction --mpd-port <PORT> MPD port for MPD pipeline [default: 6600]
--mpd-host <HOST> MPD host for cover extraction [default: localhost]
--mpd-port <PORT> MPD port for cover extraction [default: 6600]
--db-path <PATH> Path to the SQLite database --db-path <PATH> Path to the SQLite database
scrbblr repair-mpd-covers [OPTIONS] scrbblr repair-mpd-covers [OPTIONS]
@@ -298,28 +297,36 @@ scrbblr pin-album [OPTIONS]
### Enrichment (covers + genres) ### Enrichment (covers + genres)
Enrichment runs in two stages: `enrich` has a clean 3-part model:
- `--pipeline` decides *where* to run enrichment (`mpd`, `online`, `both`)
- `--fetch` decides *what* to fetch online (`covers`, `genres`, `all`)
- `--retry` decides *which missing rows* should bypass cooldown before online lookup
Defaults are: `--pipeline both --fetch all --retry none`.
The two pipelines are:
**Stage 1 -- MPD embedded covers (offline, no network)** **Stage 1 -- MPD embedded covers (offline, no network)**
By default, `enrich` connects to MPD and extracts embedded cover art from your music files using MPD's `readpicture` command. This is fast, works entirely offline, and only processes albums scrobbled via MPD that don't yet have a cover: By default (or with `--pipeline mpd`), `enrich` connects to MPD and extracts embedded cover art from your music files using MPD's `readpicture` command. This is fast, works entirely offline, and only processes albums scrobbled via MPD that don't yet have a cover:
```bash ```bash
scrbblr enrich scrbblr enrich --pipeline mpd
``` ```
**Stage 2 -- Online lookup (MusicBrainz + iTunes + Cover Art Archive)** **Stage 2 -- Online lookup (MusicBrainz + iTunes + Cover Art Archive)**
Pass `--online` to also query MusicBrainz for album metadata (MBID, genres) and download covers for albums that still have none after the MPD pass: Use the online pipeline to query MusicBrainz for metadata (MBID, genres) and fetch covers:
```bash ```bash
scrbblr enrich --online scrbblr enrich --pipeline online --fetch all
``` ```
Cover art is sourced from **iTunes first** (fast, no rate limiting), then the **Cover Art Archive** as fallback. Pass `--no-itunes` to skip iTunes and use only CAA: Cover art is sourced from **iTunes first** (fast, no rate limiting), then the **Cover Art Archive** as fallback. To use only CAA:
```bash ```bash
scrbblr enrich --online --no-itunes scrbblr enrich --pipeline online --fetch covers --cover-source caa
``` ```
When MusicBrainz matching is tricky, enrichment retries with normalised album variants (strips parenthetical suffixes, progressively shortens trailing words), tries artist aliases for symbol-heavy names, and falls back to recording search before giving up. When MusicBrainz matching is tricky, enrichment retries with normalised album variants (strips parenthetical suffixes, progressively shortens trailing words), tries artist aliases for symbol-heavy names, and falls back to recording search before giving up.
@@ -331,24 +338,26 @@ Genre extraction order:
3. Release-group `genres` 3. Release-group `genres`
4. Release-group `tags` 4. Release-group `tags`
Automatic enrichment (triggered by `report --html`) uses a 7-day retry cooldown for incomplete cache entries (missing cover or genre) so it doesn't hammer MusicBrainz on every report run. Use `--retry-covers` to reset that cooldown and retry albums missing covers immediately: Automatic enrichment (triggered by `report --html`) uses a 7-day retry cooldown for incomplete cache entries (missing cover or genre) so it doesn't hammer MusicBrainz on every report run.
Retry missing covers immediately:
```bash ```bash
scrbblr enrich --online --retry-covers scrbblr enrich --pipeline online --retry covers
``` ```
If you specifically want to retry **missing genres for MPD-sourced albums only**: Retry missing genres only for MPD-sourced albums:
```bash ```bash
scrbblr enrich --online --retry-mpd-genres scrbblr enrich --pipeline online --fetch genres --retry mpd-genres
``` ```
You can combine this with `--artist` to target one artist. 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 (online pipeline only):
```bash ```bash
scrbblr enrich --online --force scrbblr enrich --pipeline online --fetch all --force
``` ```
Genre normalisation notes: Genre normalisation notes:

161
src/db.rs
View File

@@ -737,7 +737,8 @@ pub struct UncachedAlbum {
/// have no `cover_url`. This makes [`uncached_albums`] pick them up again on /// have no `cover_url`. This makes [`uncached_albums`] pick them up again on
/// the next enrichment run, bypassing the 7-day cooldown. /// the next enrichment run, bypassing the 7-day cooldown.
/// ///
/// Used by `enrich --online --retry-covers` to re-attempt cover downloads /// Used by `enrich --pipeline online --retry covers` to re-attempt cover
/// downloads
/// without forcing a full re-fetch of all albums. /// without forcing a full re-fetch of all albums.
pub fn reset_missing_cover_timestamps(conn: &Connection) -> Result<usize> { pub fn reset_missing_cover_timestamps(conn: &Connection) -> Result<usize> {
let count = conn.execute( let count = conn.execute(
@@ -762,6 +763,60 @@ pub fn reset_missing_cover_timestamps_for_artist(conn: &Connection, artist: &str
Ok(count) Ok(count)
} }
/// Reset the `fetched_at` timestamp to NULL for all rows that have no genre.
///
/// Used when the user explicitly wants to retry genre lookups immediately,
/// bypassing the default 7-day cooldown.
pub fn reset_missing_genre_timestamps(conn: &Connection) -> Result<usize> {
let count = conn.execute(
"UPDATE album_cache
SET fetched_at = NULL
WHERE genre IS NULL",
[],
)?;
Ok(count)
}
/// Reset `fetched_at` to NULL for genre-missing albums for one artist only.
///
/// Matching is case-insensitive (`LOWER(artist) = LOWER(?1)`).
pub fn reset_missing_genre_timestamps_for_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)",
params![artist],
)?;
Ok(count)
}
/// Reset `fetched_at` to NULL for rows missing either cover or genre.
pub fn reset_incomplete_timestamps(conn: &Connection) -> Result<usize> {
let count = conn.execute(
"UPDATE album_cache
SET fetched_at = NULL
WHERE cover_url IS NULL OR genre IS NULL",
[],
)?;
Ok(count)
}
/// Reset `fetched_at` to NULL for incomplete rows for one artist only.
///
/// A row is considered incomplete when either `cover_url` or `genre` is NULL.
/// Matching is case-insensitive (`LOWER(artist) = LOWER(?1)`).
pub fn reset_incomplete_timestamps_for_artist(conn: &Connection, artist: &str) -> Result<usize> {
let count = conn.execute(
"UPDATE album_cache
SET fetched_at = NULL
WHERE (cover_url IS NULL OR genre IS NULL)
AND LOWER(artist) = LOWER(?1)",
params![artist],
)?;
Ok(count)
}
/// Reset `fetched_at` to NULL for MPD-sourced albums that have no genre yet. /// 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 /// This allows online enrichment to re-try genre lookups immediately for
@@ -2152,6 +2207,110 @@ mod tests {
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_artist_only() {
let conn = open_memory_db().unwrap();
upsert_album_cache(
&conn,
&AlbumCacheEntry {
artist: "Pink Floyd".into(),
album: "The Wall".into(),
musicbrainz_id: Some("mbid-1".into()),
cover_url: Some("covers/x.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/y.jpg".into()),
genre: None,
fetched_at: today_at("12:01:00"),
},
)
.unwrap();
let updated = reset_missing_genre_timestamps_for_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()));
}
#[test]
fn test_reset_incomplete_timestamps_for_artist_only() {
let conn = open_memory_db().unwrap();
upsert_album_cache(
&conn,
&AlbumCacheEntry {
artist: "Pink Floyd".into(),
album: "The Wall".into(),
musicbrainz_id: Some("mbid-1".into()),
cover_url: None,
genre: Some("progressive rock".into()),
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/y.jpg".into()),
genre: None,
fetched_at: today_at("12:01:00"),
},
)
.unwrap();
let updated = reset_incomplete_timestamps_for_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()));
}
#[test] #[test]
fn test_reset_missing_genre_timestamps_for_mpd_only() { fn test_reset_missing_genre_timestamps_for_mpd_only() {
let conn = open_memory_db().unwrap(); let conn = open_memory_db().unwrap();

View File

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

View File

@@ -55,7 +55,7 @@ mod mpd;
mod report; mod report;
mod watcher; mod watcher;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand, ValueEnum};
use std::io::BufRead; use std::io::BufRead;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -77,6 +77,39 @@ struct Cli {
command: Commands, command: Commands,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
#[value(rename_all = "kebab-case")]
enum EnrichPipeline {
Mpd,
Online,
Both,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
#[value(rename_all = "kebab-case")]
enum EnrichFetch {
All,
Covers,
Genres,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
#[value(rename_all = "kebab-case")]
enum EnrichRetry {
None,
Covers,
Genres,
All,
MpdGenres,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
#[value(rename_all = "kebab-case")]
enum CoverSource {
ItunesCaa,
Caa,
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Watch playerctl metadata and MPD, scrobbling tracks to the local database. /// Watch playerctl metadata and MPD, scrobbling tracks to the local database.
@@ -164,40 +197,40 @@ enum Commands {
}, },
/// Fetch album art and genre info for all scrobbled albums. /// Fetch album art and genre info for all scrobbled albums.
/// ///
/// By default, connects to MPD and extracts embedded cover art from music /// By default, runs both stages:
/// files via `readpicture` — fully offline, no network access required.
/// Pass `--no-mpd-covers` to skip this step.
/// ///
/// Pass `--online` to also query MusicBrainz for album metadata (MBID, /// 1. MPD embedded-cover extraction (`readpicture`, offline)
/// genre) and download cover art. Cover art is sourced from iTunes first /// 2. Online metadata/cover enrichment (MusicBrainz + iTunes/CAA)
/// (fast, no rate limit), then the Cover Art Archive as fallback. Pass ///
/// `--no-itunes` to skip iTunes and use only the Cover Art Archive. /// Use `--pipeline`, `--fetch`, and `--retry` to narrow the behaviour.
Enrich { Enrich {
/// Query MusicBrainz for album metadata (MBID, genre) and download /// Which enrichment stage(s) to run.
/// cover art from the Cover Art Archive for albums that still have #[arg(long, value_enum, default_value = "both")]
/// no cover after local extraction. pipeline: EnrichPipeline,
#[arg(long)]
online: bool, /// Which metadata to fetch during the online stage.
#[arg(long, value_enum, default_value = "all")]
fetch: EnrichFetch,
/// Reset cooldown before online enrichment.
///
/// - `none`: keep normal 7-day cooldown
/// - `covers`: retry cover-missing rows
/// - `genres`: retry genre-missing rows
/// - `all`: retry rows missing either cover or genre
/// - `mpd-genres`: retry genre-missing rows only for MPD-sourced albums
#[arg(long, value_enum, default_value = "none")]
retry: EnrichRetry,
/// Cover source policy for the online stage.
#[arg(long, value_enum, default_value = "itunes-caa")]
cover_source: CoverSource,
/// Re-fetch metadata for all albums from MusicBrainz, even those /// Re-fetch metadata for all albums from MusicBrainz, even those
/// already cached. Only meaningful together with `--online`. /// already cached. Only meaningful when `--pipeline` includes `online`.
#[arg(long)] #[arg(long)]
force: bool, force: bool,
/// Re-attempt cover downloads for albums that have metadata cached
/// but no cover art yet. Resets the 7-day cooldown for those albums
/// only, so the next `--online` run retries them. Does not affect
/// albums that already have a cover.
#[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). /// 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
@@ -206,25 +239,12 @@ enum Commands {
#[arg(long)] #[arg(long)]
artist: Option<String>, artist: Option<String>,
/// Skip the iTunes Search API when fetching cover art. Falls back
/// directly to the Cover Art Archive (CAA). Useful if you prefer
/// open-data sources or iTunes results are poor for your library.
#[arg(long)]
no_itunes: bool,
/// Disable the MPD embedded cover extraction step. By default,
/// `enrich` connects to MPD and extracts embedded cover art from
/// music files via `readpicture` before doing anything else.
#[arg(long)]
no_mpd_covers: bool,
/// MPD server hostname, IP address, or Unix socket path. /// MPD server hostname, IP address, or Unix socket path.
/// Used for embedded cover extraction (unless `--no-mpd-covers`). /// Used for MPD extraction when `--pipeline` includes `mpd`.
#[arg(long, default_value = "localhost")] #[arg(long, default_value = "localhost")]
mpd_host: String, mpd_host: String,
/// MPD server TCP port. Used for embedded cover extraction when /// MPD server TCP port. Ignored for Unix socket paths.
/// connecting via TCP (ignored for Unix socket paths).
#[arg(long, default_value = "6600")] #[arg(long, default_value = "6600")]
mpd_port: u16, mpd_port: u16,
@@ -595,7 +615,7 @@ fn run_report(
// iTunes is tried first (fast, no rate limit), then CAA as fallback. // iTunes is tried first (fast, no rate limit), then CAA as fallback.
// Respects the 7-day cooldown so we don't hammer MusicBrainz on every // Respects the 7-day cooldown so we don't hammer MusicBrainz on every
// report run. // report run.
enrich::run_enrich_targeted(&conn, &needed, true, false); enrich::run_enrich_targeted(&conn, &needed, true);
} }
if html { if html {
@@ -755,29 +775,38 @@ fn main() {
); );
} }
Commands::Enrich { Commands::Enrich {
online, pipeline,
fetch,
retry,
cover_source,
force, force,
retry_covers,
retry_mpd_genres,
artist, artist,
no_itunes,
no_mpd_covers,
mpd_host, mpd_host,
mpd_port, mpd_port,
db_path, db_path,
} => { } => {
if retry_mpd_genres && !online { let run_mpd = matches!(pipeline, EnrichPipeline::Mpd | EnrichPipeline::Both);
eprintln!("--retry-mpd-genres requires --online."); let run_online = matches!(pipeline, EnrichPipeline::Online | EnrichPipeline::Both);
let wants_covers = matches!(fetch, EnrichFetch::All | EnrichFetch::Covers);
if force && !run_online {
eprintln!("--force requires --pipeline online or --pipeline both.");
std::process::exit(1); std::process::exit(1);
} }
if no_mpd_covers && !online { if !matches!(retry, EnrichRetry::None) && !run_online {
eprintln!("Nothing to do. Pass --online and/or omit --no-mpd-covers."); eprintln!("--retry requires --pipeline online or --pipeline both.");
std::process::exit(1);
}
if run_mpd && !wants_covers && !run_online {
eprintln!( eprintln!(
" (default) Extract embedded covers from music files via MPD (offline)" "Nothing to do: --pipeline mpd only handles covers, but --fetch {} excludes covers.",
); match fetch {
eprintln!( EnrichFetch::Genres => "genres",
" --online Fetch metadata and covers from MusicBrainz / iTunes / CAA" EnrichFetch::All => "all",
EnrichFetch::Covers => "covers",
}
); );
std::process::exit(0); std::process::exit(0);
} }
@@ -816,10 +845,10 @@ fn main() {
None None
}; };
// Run MPD cover extraction first (fast, local, no rate limits). // Run MPD cover extraction first (fast, local, no rate limits)
// Pre-populating cover_url means the online enrichment that follows // when the selected pipeline includes MPD and the fetch mode asks
// will skip fetching covers for albums that already have one. // for covers.
if !no_mpd_covers { if run_mpd && wants_covers {
let mpd_cfg = mpd::MpdConfig { let mpd_cfg = mpd::MpdConfig {
host: mpd_host, host: mpd_host,
port: mpd_port, port: mpd_port,
@@ -831,45 +860,79 @@ fn main() {
} }
} }
// Online enrichment: MusicBrainz lookup for MBID + genre, Cover Art if run_online {
// Archive for any albums still missing a cover after the MPD pass. // Optional retry selector: reset cooldown timestamps so the
// Reset the cooldown for cover-missing albums before the online // online stage revisits selected missing fields immediately.
// pass so they are picked up by uncached_albums. if !matches!(retry, EnrichRetry::None) {
if retry_covers { let reset = match retry {
let reset = if let Some(ref artist_name) = artist { EnrichRetry::None => Ok(0),
db::reset_missing_cover_timestamps_for_artist(&conn, artist_name) EnrichRetry::Covers => {
} else { if let Some(ref artist_name) = artist {
db::reset_missing_cover_timestamps(&conn) db::reset_missing_cover_timestamps_for_artist(&conn, artist_name)
}; } else {
db::reset_missing_cover_timestamps(&conn)
}
}
EnrichRetry::Genres => {
if let Some(ref artist_name) = artist {
db::reset_missing_genre_timestamps_for_artist(&conn, artist_name)
} else {
db::reset_missing_genre_timestamps(&conn)
}
}
EnrichRetry::All => {
if let Some(ref artist_name) = artist {
db::reset_incomplete_timestamps_for_artist(&conn, artist_name)
} else {
db::reset_incomplete_timestamps(&conn)
}
}
EnrichRetry::MpdGenres => {
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 { match reset {
Ok(n) => eprintln!("Reset cooldown for {} album(s) missing covers.", n), Ok(n) => eprintln!(
Err(e) => eprintln!("[warn] Failed to reset cover timestamps: {}", e), "Reset cooldown for {} album(s) via --retry {}.",
} n,
} match retry {
EnrichRetry::None => "none",
if retry_mpd_genres { EnrichRetry::Covers => "covers",
let reset = if let Some(ref artist_name) = artist { EnrichRetry::Genres => "genres",
db::reset_missing_genre_timestamps_for_mpd_artist(&conn, artist_name) EnrichRetry::All => "all",
} else { EnrichRetry::MpdGenres => "mpd-genres",
db::reset_missing_genre_timestamps_for_mpd(&conn) }
}; ),
Err(e) => eprintln!("[warn] Failed to reset retry timestamps: {}", e),
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 { let cover_source_mode = match cover_source {
CoverSource::ItunesCaa => enrich::CoverSourceMode::ItunesThenCaa,
CoverSource::Caa => enrich::CoverSourceMode::CaaOnly,
};
let fetch_mode = match fetch {
EnrichFetch::All => enrich::OnlineFetchMode::All,
EnrichFetch::Covers => enrich::OnlineFetchMode::Covers,
EnrichFetch::Genres => enrich::OnlineFetchMode::Genres,
};
let options = enrich::OnlineEnrichOptions {
fetch_mode,
cover_source: cover_source_mode,
};
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(&conn, needed, false, force, options);
&conn, needed, false, no_itunes, force,
);
} else { } else {
enrich::run_enrich(&conn, force, false, no_itunes); enrich::run_enrich_with_options(&conn, force, false, options);
} }
} }
} }