Alternative source for covers
This commit is contained in:
14
src/db.rs
14
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<usize> {
|
||||
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<Vec<UncachedAlbum>> {
|
||||
// Retry incomplete cache entries at most once per 7 days.
|
||||
let retry_before = (chrono::Local::now().naive_local() - chrono::Duration::days(7))
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<String> {
|
||||
// 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<db::UncachedAlbum>, 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<db::UncachedAlbum>, 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 {
|
||||
|
||||
17
src/main.rs
17
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user