From 1976df2f52611a707fe7b417aed5515db8ba355c Mon Sep 17 00:00:00 2001 From: Artur Meski Date: Sun, 22 Mar 2026 11:30:45 +0000 Subject: [PATCH] Alternative source for covers --- src/db.rs | 14 ++++++++ src/enrich.rs | 96 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 17 +++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/db.rs b/src/db.rs index c4220b8..0efefcb 100644 --- a/src/db.rs +++ b/src/db.rs @@ -685,6 +685,20 @@ pub struct UncachedAlbum { /// This ensures albums are re-tried if a previous run failed to find cover art /// or genre metadata, but only after a cooldown to avoid hitting MusicBrainz /// on every single report generation. +/// Reset the `fetched_at` timestamp to NULL for all `album_cache` rows that +/// have no `cover_url`. This makes [`uncached_albums`] pick them up again on +/// the next enrichment run, bypassing the 7-day cooldown. +/// +/// Used by `enrich --online --retry-covers` to re-attempt cover downloads +/// without forcing a full re-fetch of all albums. +pub fn reset_missing_cover_timestamps(conn: &Connection) -> Result { + let count = conn.execute( + "UPDATE album_cache SET fetched_at = NULL WHERE cover_url IS NULL", + [], + )?; + Ok(count) +} + pub fn uncached_albums(conn: &Connection) -> Result> { // Retry incomplete cache entries at most once per 7 days. let retry_before = (chrono::Local::now().naive_local() - chrono::Duration::days(7)) diff --git a/src/enrich.rs b/src/enrich.rs index b9e234e..d5e545c 100644 --- a/src/enrich.rs +++ b/src/enrich.rs @@ -38,6 +38,11 @@ //! - Cover Art Archive (release-group, any edition): //! `https://coverartarchive.org/release-group/{rgid}/front` //! +//! - iTunes Search API (fallback when CAA has no cover): +//! `https://itunes.apple.com/search?term={artist}+{album}&entity=album&limit=5` +//! Returns `artworkUrl100`; the dimension suffix is bumped to `600x600bb` +//! before downloading. No authentication required. +//! //! ## User-Agent //! //! MusicBrainz requires a descriptive User-Agent header. We use: @@ -763,6 +768,61 @@ pub fn resize_cover_bytes(bytes: &[u8]) -> Option> { Some(out) } +// --------------------------------------------------------------------------- +// iTunes Search API — cover art fallback +// --------------------------------------------------------------------------- + +/// Generate a stable filename stem for a cover sourced from iTunes. +/// +/// Uses the same FNV-1a 64-bit hash approach as the MPD cover extractor, +/// keyed on `"artist\talbum"`, so the filename is deterministic across runs. +/// Prefixed with `"itunes_"` to distinguish from MBID-based filenames. +fn itunes_cover_stem(artist: &str, album: &str) -> String { + const FNV_OFFSET: u64 = 14695981039346656037; + const FNV_PRIME: u64 = 1099511628211; + + let input = format!("{}\t{}", artist, album); + let mut hash = FNV_OFFSET; + for byte in input.bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(FNV_PRIME); + } + format!("itunes_{:016x}", hash) +} + +/// Search the iTunes Store for the front cover of an album. +/// +/// Uses Apple's public search API — no authentication required. Searches +/// for `"{artist} {album}"` with `entity=album`, takes the first result, +/// and returns a URL to the 600×600 version of the artwork (upgraded from +/// the default 100×100 thumbnail in the API response). +/// +/// Returns `None` if no results are found or the request fails. +fn fetch_itunes_cover_url(client: &Client, artist: &str, album: &str) -> Option { + // Combine artist and album into a single search query. + let query = urlencoded(&format!("{} {}", artist, album)); + let url = format!( + "https://itunes.apple.com/search?term={}&entity=album&limit=5", + query + ); + + let response = client.get(&url).send().ok()?; + if !response.status().is_success() { + return None; + } + + let json: serde_json::Value = response.json().ok()?; + let results = json.get("results")?.as_array()?; + + // Take the first result's artwork URL and bump the resolution. + // Apple's URLs end in "100x100bb.jpg" — replace with "600x600bb". + results.iter().find_map(|r| { + r.get("artworkUrl100")? + .as_str() + .map(|s| s.replace("100x100bb", "600x600bb")) + }) +} + /// Attempt to download an image from the given URL and save it to `dest`. /// The image is resized to at most `MAX_COVER_DIM` pixels per side before /// being written, to keep cached covers from consuming excessive disk space. @@ -892,14 +952,29 @@ fn run_enrich_albums(conn: &Connection, albums: Vec, quiet: b let candidates = search_releases(&client, &album.artist, &album.album); if candidates.is_empty() { - eprintln!(" No match found on MusicBrainz."); + eprintln!(" No match found on MusicBrainz. Trying iTunes..."); - // Cache a "no match" entry so we don't re-query next time. + // Fall back to iTunes for cover art even without an MBID. + let cover = fetch_itunes_cover_url(&client, &album.artist, &album.album) + .and_then(|art_url| { + let stem = itunes_cover_stem(&album.artist, &album.album); + let dest = covers.join(format!("{}.jpg", stem)); + try_download(&client, &art_url, &dest) + }); + + if cover.is_some() { + eprintln!(" Cover downloaded (from iTunes)."); + cover_count += 1; + } else { + eprintln!(" No cover art available from any source."); + } + + // Cache the entry (with or without cover) so we don't re-query next time. let entry = AlbumCacheEntry { artist: album.artist.clone(), album: album.album.clone(), musicbrainz_id: None, - cover_url: None, + cover_url: cover, genre: None, fetched_at: now_str(), }; @@ -948,6 +1023,21 @@ fn run_enrich_albums(conn: &Connection, albums: Vec, quiet: b } } + // Step 5: If still no cover, try iTunes as a last resort. + if cover.is_none() { + eprintln!(" No cover from CAA. Trying iTunes..."); + cover = fetch_itunes_cover_url(&client, &album.artist, &album.album) + .and_then(|art_url| { + // Reuse the MBID-based filename so the file is still + // associated with the release even though it came from iTunes. + let dest = covers.join(format!("{}.jpg", primary_mbid)); + try_download(&client, &art_url, &dest) + }); + if cover.is_some() { + eprintln!(" Cover downloaded (from iTunes)."); + } + } + if cover.is_some() { cover_count += 1; } else { diff --git a/src/main.rs b/src/main.rs index bbc3f47..d6a1e2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -168,6 +168,13 @@ enum Commands { #[arg(long)] 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, + /// 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. @@ -655,6 +662,7 @@ fn main() { Commands::Enrich { online, force, + retry_covers, no_mpd_covers, mpd_host, mpd_port, @@ -689,6 +697,15 @@ fn main() { // Online enrichment: MusicBrainz lookup for MBID + genre, Cover Art // Archive for any albums still missing a cover after the MPD pass. + // Reset the cooldown for cover-missing albums before the online + // pass so they are picked up by uncached_albums. + if retry_covers { + match db::reset_missing_cover_timestamps(&conn) { + Ok(n) => eprintln!("Reset cooldown for {} album(s) missing covers.", n), + Err(e) => eprintln!("[warn] Failed to reset cover timestamps: {}", e), + } + } + if online { enrich::run_enrich(&conn, force, false); }