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
|
/// 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
|
/// or genre metadata, but only after a cooldown to avoid hitting MusicBrainz
|
||||||
/// on every single report generation.
|
/// 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>> {
|
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))
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
//! - Cover Art Archive (release-group, any edition):
|
//! - Cover Art Archive (release-group, any edition):
|
||||||
//! `https://coverartarchive.org/release-group/{rgid}/front`
|
//! `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
|
//! ## User-Agent
|
||||||
//!
|
//!
|
||||||
//! MusicBrainz requires a descriptive User-Agent header. We use:
|
//! 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)
|
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`.
|
/// 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
|
/// 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.
|
/// 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);
|
let candidates = search_releases(&client, &album.artist, &album.album);
|
||||||
|
|
||||||
if candidates.is_empty() {
|
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 {
|
let entry = AlbumCacheEntry {
|
||||||
artist: album.artist.clone(),
|
artist: album.artist.clone(),
|
||||||
album: album.album.clone(),
|
album: album.album.clone(),
|
||||||
musicbrainz_id: None,
|
musicbrainz_id: None,
|
||||||
cover_url: None,
|
cover_url: cover,
|
||||||
genre: None,
|
genre: None,
|
||||||
fetched_at: now_str(),
|
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() {
|
if cover.is_some() {
|
||||||
cover_count += 1;
|
cover_count += 1;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
17
src/main.rs
17
src/main.rs
@@ -168,6 +168,13 @@ enum Commands {
|
|||||||
#[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,
|
||||||
|
|
||||||
/// Disable the MPD embedded cover extraction step. By default,
|
/// Disable the MPD embedded cover extraction step. By default,
|
||||||
/// `enrich` connects to MPD and extracts embedded cover art from
|
/// `enrich` connects to MPD and extracts embedded cover art from
|
||||||
/// music files via `readpicture` before doing anything else.
|
/// music files via `readpicture` before doing anything else.
|
||||||
@@ -655,6 +662,7 @@ fn main() {
|
|||||||
Commands::Enrich {
|
Commands::Enrich {
|
||||||
online,
|
online,
|
||||||
force,
|
force,
|
||||||
|
retry_covers,
|
||||||
no_mpd_covers,
|
no_mpd_covers,
|
||||||
mpd_host,
|
mpd_host,
|
||||||
mpd_port,
|
mpd_port,
|
||||||
@@ -689,6 +697,15 @@ fn main() {
|
|||||||
|
|
||||||
// Online enrichment: MusicBrainz lookup for MBID + genre, Cover Art
|
// Online enrichment: MusicBrainz lookup for MBID + genre, Cover Art
|
||||||
// Archive for any albums still missing a cover after the MPD pass.
|
// 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 {
|
if online {
|
||||||
enrich::run_enrich(&conn, force, false);
|
enrich::run_enrich(&conn, force, false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user