//! Enrich module — fetches album metadata from MusicBrainz and cover art //! from the Cover Art Archive. //! //! This module is used in two ways: //! - Explicitly via the `enrich` CLI subcommand. //! - Automatically when `report --html` is used (to ensure covers are //! available before generating the HTML). //! //! The enrichment process: //! //! 1. Queries the database for all unique (artist, album) pairs that don't //! yet have an entry in `album_cache`. //! 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). 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) //! c. Other candidate releases from the search results //! 5. Stores the metadata in `album_cache` and the cover image on disk. //! //! ## Rate limiting //! //! MusicBrainz requires a maximum of 1 request per second. We enforce this //! by sleeping between API calls. The Cover Art Archive has no documented //! rate limit, but we apply the same 1 req/sec policy to be a good citizen. //! iTunes has no documented rate limit and is not throttled. //! //! ## API endpoints used //! //! - MusicBrainz release search: //! `https://musicbrainz.org/ws/2/release/?query=artist:{}&release:{}&fmt=json` //! //! - MusicBrainz release details (for tags/genres and release-group ID): //! `https://musicbrainz.org/ws/2/release/{mbid}?inc=genres+tags+release-groups&fmt=json` //! //! - iTunes Search API (primary cover source): //! `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. //! //! - Cover Art Archive (release-specific, fallback): //! `https://coverartarchive.org/release/{mbid}/front` //! //! - Cover Art Archive (release-group, any edition, fallback): //! `https://coverartarchive.org/release-group/{rgid}/front` //! //! ## User-Agent //! //! MusicBrainz requires a descriptive User-Agent header. We use: //! `scrbblr/0.1.0 (https://github.com/arturmeski/scrbblr)` use crate::db::{self, AlbumCacheEntry}; use image::codecs::jpeg::JpegEncoder; use reqwest::blocking::Client; use rusqlite::Connection; use serde::Deserialize; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; /// User-Agent string sent with all HTTP requests to MusicBrainz. /// Required by MusicBrainz API policy: /// https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting const USER_AGENT: &str = "scrbblr/0.1.0 (https://github.com/arturmeski/scrbblr)"; /// Build the shared HTTP client used for all MusicBrainz and Cover Art Archive requests. fn build_client() -> Client { Client::builder() .user_agent(USER_AGENT) // Follow redirects (Cover Art Archive uses them). .redirect(reqwest::redirect::Policy::limited(5)) .timeout(Duration::from_secs(30)) .build() .expect("Failed to build HTTP client") } /// Return the current local time formatted as an ISO 8601 datetime string. fn now_str() -> String { chrono::Local::now() .naive_local() .format("%Y-%m-%dT%H:%M:%S") .to_string() } /// Minimum delay between API requests to respect MusicBrainz rate limits. const RATE_LIMIT_DELAY: Duration = Duration::from_millis(1100); /// Maximum dimension (width or height) for cached cover art in pixels. /// Images larger than this are downscaled before saving. const MAX_COVER_DIM: u32 = 500; /// JPEG quality used when re-encoding resized cover art (0–100). 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) // --------------------------------------------------------------------------- /// Response from the MusicBrainz release search endpoint. #[derive(Debug, Deserialize)] struct MbReleaseSearchResponse { releases: Vec, } /// A single release from MusicBrainz search results. #[derive(Debug, Deserialize)] struct MbRelease { /// The MusicBrainz release ID (UUID). id: String, /// Release title. #[allow(dead_code)] title: String, /// Match score (0-100) indicating how well this result matches the query. score: u32, } /// Response from the MusicBrainz recording search endpoint. #[derive(Debug, Deserialize)] struct MbRecordingSearchResponse { #[serde(default)] recordings: Vec, } /// A single recording result from MusicBrainz. #[derive(Debug, Deserialize)] struct MbRecording { /// Match score (0-100) indicating how well this result matches the query. score: u32, /// Releases containing this recording. #[serde(default)] releases: Vec, } /// A lightweight release reference from recording results. #[derive(Debug, Deserialize)] struct MbRecordingRelease { id: String, } /// Response from the MusicBrainz release details endpoint (with genres/tags /// and release-group info). #[derive(Debug, Deserialize)] struct MbReleaseDetails { /// Genre tags associated with the release. #[serde(default)] genres: Vec, /// User-submitted tags (broader than genres, includes styles, moods, etc.). #[serde(default)] tags: Vec, /// The release-group this release belongs to. All editions of the same /// album share one release-group. #[serde(rename = "release-group")] release_group: Option, } /// Response from the MusicBrainz release-group details endpoint. #[derive(Debug, Deserialize)] struct MbReleaseGroupDetails { /// Genre tags associated with the release-group. #[serde(default)] genres: Vec, /// User-submitted tags for the release-group. #[serde(default)] tags: Vec, } /// A release-group from MusicBrainz. Groups all editions/pressings of /// the same album under a single ID. #[derive(Debug, Deserialize)] struct MbReleaseGroup { id: String, } /// A genre entry from MusicBrainz. #[derive(Debug, Deserialize)] struct MbGenre { name: String, } /// A tag entry from MusicBrainz (user-submitted, with vote count). #[derive(Debug, Deserialize)] struct MbTag { name: String, /// Number of votes for this tag. Higher = more reliable. #[serde(default)] count: i32, } // --------------------------------------------------------------------------- // Cover art directory // --------------------------------------------------------------------------- /// Get the directory where cover art images are stored, creating it if needed. /// /// Default: `~/.local/share/scrbblr/covers/` /// (respects `$XDG_DATA_HOME`) pub fn covers_dir() -> PathBuf { let data_dir = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| { let home = std::env::var("HOME").expect("HOME not set"); format!("{}/.local/share", home) }); let dir = PathBuf::from(format!("{}/scrbblr/covers", data_dir)); fs::create_dir_all(&dir).expect("Failed to create covers directory"); dir } // --------------------------------------------------------------------------- // MusicBrainz API functions // --------------------------------------------------------------------------- /// Search MusicBrainz for releases matching the given artist and album name. /// /// Returns a list of MusicBrainz release IDs (MBIDs) with score >= 80, /// ordered by score descending. Returns up to 5 candidates so we can try /// multiple releases for cover art. /// /// Uses progressively looser search strategies while keeping the artist field /// exact to avoid false matches: /// 1. Exact Lucene field search: `artist:"X" AND release:"Y"` /// 2. Retry with parenthetical/bracketed suffixes removed /// (e.g., "If (Killing Eve)" -> "If") /// 3. Progressively shorten cleaned titles by dropping trailing words /// 4. Retry with artist aliases (e.g. "††† (Crosses)" -> "Crosses") /// 5. Fallback via recording search when release search yields nothing fn search_releases(client: &Client, artist: &str, album: &str) -> Vec { let artist_variants = artist_search_variants(artist); let album_variants = album_search_variants(album); if artist_variants.is_empty() || album_variants.is_empty() { return vec![]; } // Strategy 1/2/3: exact artist, then album variants. for (idx, variant) in album_variants.iter().enumerate() { if idx > 0 { eprintln!(" Retrying with alternate album name: \"{}\"...", variant); thread::sleep(RATE_LIMIT_DELAY); } let query = format!( "artist:\"{}\" AND release:\"{}\"", artist_variants[0], variant ); let results = run_mb_search(client, &query); if !results.is_empty() { return results; } } // Strategy 4: retry with alternate artist aliases and relaxed score. for alt_artist in artist_variants.iter().skip(1) { eprintln!( " Retrying with alternate artist name: \"{}\"...", alt_artist ); for (idx, variant) in album_variants.iter().enumerate() { if idx > 0 { eprintln!(" Retrying with alternate album name: \"{}\"...", variant); } thread::sleep(RATE_LIMIT_DELAY); let query = format!("artist:\"{}\" AND release:\"{}\"", alt_artist, variant); let results = run_mb_search_with_min_score(client, &query, 60); if !results.is_empty() { return results; } } } // Strategy 5: fallback via recording search. This helps when metadata // contains track-ish album names like "If (Killing Eve)". let recording_query = album_variants .first() .cloned() .unwrap_or_else(|| album.to_string()); eprintln!( " Retrying via recording search for \"{}\"...", recording_query ); thread::sleep(RATE_LIMIT_DELAY); let mut recording_results = run_mb_recording_search( client, &format!( "artist:\"{}\" AND recording:\"{}\"", artist_variants[0], recording_query ), 60, ); if !recording_results.is_empty() { return recording_results; } if let Some(shortest) = album_variants.last() { eprintln!( " Retrying via recording search for shorter name \"{}\"...", shortest ); thread::sleep(RATE_LIMIT_DELAY); recording_results = run_mb_recording_search( client, &format!( "artist:\"{}\" AND recording:\"{}\"", artist_variants[0], shortest ), 50, ); if !recording_results.is_empty() { return recording_results; } } // Strategy 6: title-only release search — no artist constraint. // Helps when the scrobbled artist doesn't match MusicBrainz release credits // (common for classical recordings credited to performers rather than composer). // Only try the first two variants (original + bracket-stripped): single-word // or two-word truncations are too broad without an artist anchor and risk // returning wrong results at score 85. for (idx, variant) in album_variants.iter().take(2).enumerate() { if idx > 0 { eprintln!( " Retrying title-only with stripped name: \"{}\"...", variant ); } else { eprintln!(" Retrying with title-only search (no artist constraint)..."); } thread::sleep(RATE_LIMIT_DELAY); let query = format!("release:\"{}\"", variant); let results = run_mb_search_with_min_score(client, &query, 85); if !results.is_empty() { return results; } } vec![] } /// Generate search variants for an album title, ordered from strict to loose. /// /// Examples: /// - "If (Killing Eve)" -> ["If (Killing Eve)", "If"] /// - "Purple Rain Deluxe Edition" -> /// ["Purple Rain Deluxe Edition", "Purple Rain Deluxe", "Purple Rain", "Purple"] fn album_search_variants(album: &str) -> Vec { let original = trim_title_edges(&normalise_spaces(album)); if original.is_empty() { return vec![]; } let mut variants: Vec = vec![original.clone()]; // Remove parenthetical/bracketed suffixes like "(Deluxe)" or "[Remastered]". let stripped = trim_title_edges(&strip_bracketed_segments(&original)); if !stripped.is_empty() && stripped != original { variants.push(stripped.clone()); } // Progressively shorten non-empty cleaned candidates down to a single word. for base in [original, stripped] { if base.is_empty() { continue; } // Avoid generating malformed cut-offs like "If (Killing" by only // shortening titles that don't contain bracketed segments. if base.contains('(') || base.contains(')') || base.contains('[') || base.contains(']') { continue; } let words: Vec<&str> = base.split_whitespace().collect(); if words.len() > 1 { for end in (1..words.len()).rev() { variants.push(words[..end].join(" ")); } } } // Deduplicate while keeping insertion order. let mut unique: Vec = Vec::new(); for v in variants { let cleaned = trim_title_edges(&v); if !cleaned.is_empty() && !unique.iter().any(|u| u == &cleaned) { unique.push(cleaned); } } unique } /// Generate artist-name variants for better matching. /// /// Example: "††† (Crosses)" -> ["††† (Crosses)", "†††", "Crosses"] fn artist_search_variants(artist: &str) -> Vec { let original = normalise_spaces(artist); if original.is_empty() { return vec![]; } let mut variants = vec![original.clone()]; // Prefer parenthetical alias if present. if let (Some(start), Some(end)) = (original.find('('), original.rfind(')')) && start < end { let before = trim_title_edges(&normalise_spaces(&original[..start])); if !before.is_empty() { variants.push(before); } let inside = normalise_spaces(&original[start + 1..end]); if !inside.is_empty() { variants.push(inside); } } // Keep alnum/space only as a final alias. let cleaned: String = original .chars() .filter(|c| c.is_alphanumeric() || c.is_whitespace()) .collect(); let cleaned = normalise_spaces(&cleaned); if !cleaned.is_empty() { variants.push(cleaned); } let mut unique = Vec::new(); for v in variants { if !unique.iter().any(|u| u == &v) { unique.push(v); } } unique } /// Collapse all whitespace runs to single spaces and trim. fn normalise_spaces(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } /// Trim common punctuation/noise from both title ends. fn trim_title_edges(s: &str) -> String { s.trim_matches(|c: char| { c.is_whitespace() || matches!( c, ',' | '.' | ';' | ':' | '!' | '?' | '-' | '_' | '"' | '\'' ) }) .to_string() } /// Remove content in balanced `(...)` and `[...]` segments. /// /// Unbalanced brackets are preserved as-is. fn strip_bracketed_segments(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut paren_depth = 0i32; let mut square_depth = 0i32; for ch in s.chars() { match ch { '(' => { paren_depth += 1; } ')' => { if paren_depth > 0 { paren_depth -= 1; } else { out.push(ch); } } '[' => { square_depth += 1; } ']' => { if square_depth > 0 { square_depth -= 1; } else { out.push(ch); } } _ => { if paren_depth == 0 && square_depth == 0 { out.push(ch); } } } } normalise_spaces(&out) } /// Execute a MusicBrainz release search with the given query string. /// Returns release IDs with score >= 80. fn run_mb_search(client: &Client, query: &str) -> Vec { run_mb_search_with_min_score(client, query, 80) } /// Execute a MusicBrainz release search with a configurable score floor. fn run_mb_search_with_min_score(client: &Client, query: &str, min_score: u32) -> Vec { let url = format!( "https://musicbrainz.org/ws/2/release/?query={}&fmt=json&limit=5", urlencoded(query) ); let response = match client.get(&url).send() { Ok(r) => r, Err(_) => return vec![], }; if !response.status().is_success() { eprintln!( " [warn] MusicBrainz search returned HTTP {}", response.status() ); return vec![]; } let body: MbReleaseSearchResponse = match response.json() { Ok(b) => b, Err(_) => return vec![], }; body.releases .into_iter() .filter(|r| r.score >= min_score) .map(|r| r.id) .collect() } /// Execute a MusicBrainz recording search and return release IDs from matches. fn run_mb_recording_search(client: &Client, query: &str, min_score: u32) -> Vec { let url = format!( "https://musicbrainz.org/ws/2/recording/?query={}&fmt=json&limit=10", urlencoded(query) ); let response = match client.get(&url).send() { Ok(r) => r, Err(_) => return vec![], }; if !response.status().is_success() { eprintln!( " [warn] MusicBrainz recording search returned HTTP {}", response.status() ); return vec![]; } let body: MbRecordingSearchResponse = match response.json() { Ok(b) => b, Err(_) => return vec![], }; let mut ids: Vec = Vec::new(); for rec in body.recordings.into_iter().filter(|r| r.score >= min_score) { for rel in rec.releases { if !ids.iter().any(|id| id == &rel.id) { ids.push(rel.id); } } } ids } /// Fetch release details from MusicBrainz, including genres/tags and the /// release-group ID. /// /// Returns `(genres_string, release_group_id)`. fn fetch_release_details(client: &Client, mbid: &str) -> (Option, Option) { let url = format!( "https://musicbrainz.org/ws/2/release/{}?inc=genres+tags+release-groups&fmt=json", mbid ); let response = match client.get(&url).send() { Ok(r) => r, Err(_) => return (None, None), }; if !response.status().is_success() { return (None, None); } let details: MbReleaseDetails = match response.json() { Ok(d) => d, Err(_) => return (None, None), }; // Extract release-group ID now so we can use it as a genre fallback. let rgid = details.release_group.map(|rg| rg.id); // Extract genres: prefer curated release genres, then release tags, // then release-group genres/tags. let mut genre = pick_genre_string(details.genres, details.tags); if genre.is_none() && let Some(ref release_group_id) = rgid { thread::sleep(RATE_LIMIT_DELAY); genre = fetch_release_group_genre(client, release_group_id); } (genre, rgid) } /// Fetch release-group genres/tags and return a genre string if available. fn fetch_release_group_genre(client: &Client, release_group_id: &str) -> Option { let url = format!( "https://musicbrainz.org/ws/2/release-group/{}?inc=genres+tags&fmt=json", release_group_id ); let response = client.get(&url).send().ok()?; if !response.status().is_success() { return None; } let details: MbReleaseGroupDetails = response.json().ok()?; pick_genre_string(details.genres, details.tags) } /// Non-genre MusicBrainz tags to discard. /// /// MusicBrainz community tags include technical metadata (encodings, years, /// language names, format codes) that are meaningless as genre labels. const TAG_BLOCKLIST: &[&str] = &[ // Languages "english", "french", "german", "spanish", "italian", "portuguese", "japanese", "korean", "chinese", "russian", "swedish", "norwegian", "danish", "dutch", "polish", "finnish", "czech", "hungarian", "turkish", "arabic", "hebrew", "greek", "romanian", "ukrainian", "catalan", // Technical / format descriptors "isrc", "cd-text", "asin", "barcode", "album", "single", "ep", "compilation", "soundtrack", "live", "instrumental", "digital", "remaster", "remastered", "deluxe", "bonus", "stereo", "mono", ]; /// Return true if a MusicBrainz tag looks like a real genre label. /// /// Rejects: /// - Tags containing digits (years like "2022", encodings like "iso-8859-1") /// - Known non-genre technical / language tags fn is_genre_tag(tag: &str) -> bool { if tag.chars().any(|c| c.is_ascii_digit()) { return false; } let lower = tag.to_ascii_lowercase(); !TAG_BLOCKLIST.contains(&lower.as_str()) } /// Build a comma-separated genre string from MusicBrainz genre/tag lists. /// /// Preference order: /// 1. Curated `genres` /// 2. Community `tags` (filtered to entries with at least one vote and /// passing the genre-tag heuristic to strip technical metadata) fn pick_genre_string(genres: Vec, tags: Vec) -> Option { let mut names: Vec = genres.into_iter().map(|g| g.name).collect(); if names.is_empty() { names = tags .into_iter() .filter(|t| t.count >= 1 && is_genre_tag(&t.name)) .map(|t| t.name) .collect(); } if names.is_empty() { None } else { Some(names.join(", ")) } } /// Try to download cover art, using multiple fallback strategies: /// /// 1. Try the specific release endpoint: `/release/{mbid}/front` /// 2. If that fails and we have a release-group ID, try: `/release-group/{rgid}/front` /// (this returns cover art from any edition of the album) /// /// Returns the local file path on success, or None if no art is found anywhere. fn download_cover_with_fallback( client: &Client, mbid: &str, release_group_id: Option<&str>, covers_dir: &Path, force_redownload: bool, ) -> Option { // Use the MBID as the filename regardless of which endpoint succeeds, // so we have a consistent cache key. let dest = covers_dir.join(format!("{}.jpg", mbid)); // Reuse cached file unless force mode explicitly asks for a refresh. if should_reuse_existing_cover(dest.exists(), force_redownload) { return Some(dest.to_string_lossy().to_string()); } // Strategy 1: Try the specific release. let release_url = format!("https://coverartarchive.org/release/{}/front", mbid); if let Some(path) = try_download(client, &release_url, &dest) { eprintln!(" Cover downloaded (from release)."); return Some(path); } // Strategy 2: Try the release-group (any edition of the album). if let Some(rgid) = release_group_id { let rg_url = format!("https://coverartarchive.org/release-group/{}/front", rgid); thread::sleep(RATE_LIMIT_DELAY); if let Some(path) = try_download(client, &rg_url, &dest) { eprintln!(" Cover downloaded (from release-group)."); return Some(path); } } None } /// Decide whether an existing cover file should be reused as-is. fn should_reuse_existing_cover(dest_exists: bool, force_redownload: bool) -> bool { dest_exists && !force_redownload } /// Decode image bytes, downscale if either dimension exceeds `MAX_COVER_DIM`, /// and re-encode as JPEG at `COVER_JPEG_QUALITY`. /// /// Returns the processed JPEG bytes, or `None` if decoding or encoding fails. /// Images already within the size limit are still re-encoded to JPEG so that /// format is consistent regardless of what the Cover Art Archive sent. pub fn resize_cover_bytes(bytes: &[u8]) -> Option> { let img = image::load_from_memory(bytes).ok()?; // Downscale only if the image exceeds the target dimension on either axis. let img = if img.width() > MAX_COVER_DIM || img.height() > MAX_COVER_DIM { img.resize( MAX_COVER_DIM, MAX_COVER_DIM, image::imageops::FilterType::Lanczos3, ) } else { img }; let mut out: Vec = Vec::new(); img.write_with_encoder(JpegEncoder::new_with_quality(&mut out, COVER_JPEG_QUALITY)) .ok()?; 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) } /// Build a relaxed normalised key for fuzzy text matching. /// /// Lowercases and keeps only alphanumeric characters and spaces, then /// collapses whitespace. This makes matching resilient to punctuation, /// symbols, and repeated spacing differences. fn normalise_match_key(s: &str) -> String { let mapped: String = s .chars() .map(|c| { if c.is_alphanumeric() || c.is_whitespace() { c } else { ' ' } }) .collect(); normalise_spaces(&mapped.to_ascii_lowercase()) } /// Return true when two strings match exactly or one contains the other, /// after relaxed normalisation. fn loosely_matches(query: &str, candidate: &str) -> bool { let q = normalise_match_key(query); let c = normalise_match_key(candidate); if q.is_empty() || c.is_empty() { return false; } q == c || q.contains(&c) || c.contains(&q) } /// Score an iTunes result for the requested artist/album. /// /// The score prefers exact artist/album matches, but still accepts useful /// near-matches (for example "The Division Bell - Single" for album field). fn score_itunes_result(result: &serde_json::Value, artist: &str, album: &str) -> Option { let result_artist = result.get("artistName")?.as_str()?; let result_album = result.get("collectionName")?.as_str()?; let _ = result.get("artworkUrl100")?.as_str()?; if !loosely_matches(artist, result_artist) || !loosely_matches(album, result_album) { return None; } let artist_q = normalise_match_key(artist); let artist_r = normalise_match_key(result_artist); let album_q = normalise_match_key(album); let album_r = normalise_match_key(result_album); let artist_score = if artist_q == artist_r { 4 } else { 2 }; let album_score = if album_q == album_r { 4 } else { 2 }; Some(artist_score + album_score) } /// Pick the best-matching iTunes artwork URL for the requested artist/album. fn pick_best_itunes_cover_url( results: &[serde_json::Value], artist: &str, album: &str, ) -> Option { let best = results .iter() .filter_map(|r| score_itunes_result(r, artist, album).map(|score| (score, r))) .max_by_key(|(score, _)| *score)?; best.1 .get("artworkUrl100")? .as_str() .map(|s| s.replace("100x100bb", "600x600bb")) } /// 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()?; // Prefer a result whose artist and album actually match the query, so // unrelated top hits don't produce obviously wrong covers. pick_best_itunes_cover_url(results, artist, album) } /// 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. /// Returns the local file path on success, or None on failure. fn try_download(client: &Client, url: &str, dest: &Path) -> Option { let response = client.get(url).send().ok()?; if !response.status().is_success() { return None; } let bytes = response.bytes().ok()?; let processed = match resize_cover_bytes(&bytes) { Some(b) => b, None => { eprintln!(" [warn] Could not decode cover image; skipping."); return None; } }; let mut file = fs::File::create(dest).ok()?; file.write_all(&processed).ok()?; Some(dest.to_string_lossy().to_string()) } // --------------------------------------------------------------------------- // URL encoding helper // --------------------------------------------------------------------------- /// Simple URL encoding for query parameters. /// Encodes characters that are not unreserved per RFC 3986. fn urlencoded(s: &str) -> String { let mut result = String::with_capacity(s.len() * 2); for c in s.chars() { match c { 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), _ => { for byte in c.to_string().as_bytes() { result.push_str(&format!("%{:02X}", byte)); } } } } result } // --------------------------------------------------------------------------- // Main enrichment function // --------------------------------------------------------------------------- /// Enrich only the albums in `needed` that aren't already cached. /// /// Used by `report --html` so we fetch covers only for what the report /// will actually display, rather than the entire scrobble library. pub fn run_enrich_targeted( conn: &Connection, needed: &std::collections::HashSet<(String, String)>, quiet: bool, ) { run_enrich_targeted_with_options(conn, needed, quiet, false, OnlineEnrichOptions::default()); } /// Like [`run_enrich_targeted`], but supports force refresh for the provided /// subset. pub fn run_enrich_targeted_with_options( conn: &Connection, needed: &std::collections::HashSet<(String, String)>, quiet: bool, force: bool, options: OnlineEnrichOptions, ) { if needed.is_empty() { if !quiet { eprintln!("No matching albums to enrich."); } return; } if force { let mut albums: Vec = needed .iter() .map(|(artist, album)| db::UncachedAlbum { artist: artist.clone(), album: album.clone(), }) .collect(); albums.sort_by(|a, b| a.artist.cmp(&b.artist).then_with(|| a.album.cmp(&b.album))); eprintln!( "Force mode enabled: refreshing {} selected album(s).", albums.len() ); run_enrich_albums(conn, albums, quiet, true, options); return; } let all_uncached = match db::uncached_albums(conn) { Ok(v) => v, Err(e) => { eprintln!("[error] Failed to query uncached albums: {}", e); return; } }; // Filter uncached list to only those the report needs. let albums: Vec = all_uncached .into_iter() .filter(|a| needed.contains(&(a.artist.clone(), a.album.clone()))) .collect(); run_enrich_albums(conn, albums, quiet, false, options); } /// Run the enrichment process: find all uncached albums and fetch their /// metadata and cover art from MusicBrainz and iTunes / Cover Art Archive. /// /// Progress is printed to stderr so the user can see what's happening. /// /// # Arguments /// /// - `conn` — SQLite database connection /// - `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) /// 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"); eprintln!("Cleared album cache (force mode)."); 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, force, options); } /// Inner enrichment loop shared by `run_enrich` and `run_enrich_targeted`. fn run_enrich_albums( conn: &Connection, albums: Vec, quiet: bool, force_redownload: bool, options: OnlineEnrichOptions, ) { if albums.is_empty() { if !quiet { eprintln!("All albums are already cached. Nothing to do."); eprintln!("Use --force to re-fetch everything."); } return; } 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!( "[{}/{}] {} - {}", i + 1, albums.len(), album.artist, 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. thread::sleep(RATE_LIMIT_DELAY); let candidates = search_releases(&client, &album.artist, &album.album); if candidates.is_empty() { eprintln!(" No match found on MusicBrainz."); // 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 !fetch_covers { None } else if !use_itunes { eprintln!(" iTunes disabled, no cover available."); None } else { eprintln!(" Trying iTunes..."); 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 if fetch_covers && use_itunes { 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: existing_mbid, cover_url: cover, genre: existing_genre, fetched_at: now_str(), }; match db::upsert_album_cache(conn, &entry) { Ok(_) => success_count += 1, Err(e) => eprintln!(" [error] Failed to cache: {}", e), } continue; } // Use the best candidate (first in the list) for metadata. let primary_mbid = &candidates[0]; eprintln!(" Found MBID: {}", primary_mbid); // Step 2: Fetch genres/tags and release-group ID from the primary release. 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 !fetch_covers { None } else if !use_itunes { None } else { let itunes_cover = fetch_itunes_cover_url(&client, &album.artist, &album.album) .and_then(|art_url| { let dest = covers.join(format!("{}.jpg", primary_mbid)); try_download(&client, &art_url, &dest) }); if itunes_cover.is_some() { eprintln!(" Cover downloaded (from iTunes)."); } itunes_cover }; // 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 fetch_covers && cover.is_none() { if use_itunes { eprintln!(" No iTunes cover. Trying Cover Art Archive..."); } // Try the primary release and its release-group first. thread::sleep(RATE_LIMIT_DELAY); cover = download_cover_with_fallback( &client, primary_mbid, release_group_id.as_deref(), &covers, force_redownload, ); // If still nothing, try alternate candidate releases from the search. if cover.is_none() && candidates.len() > 1 { eprintln!( " No cover from primary release, trying {} other candidate(s)...", candidates.len() - 1 ); for alt_mbid in &candidates[1..] { thread::sleep(RATE_LIMIT_DELAY); let alt_url = format!("https://coverartarchive.org/release/{}/front", alt_mbid); let dest = covers.join(format!("{}.jpg", primary_mbid)); if let Some(path) = try_download(&client, &alt_url, &dest) { eprintln!(" Cover downloaded (from alternate release {}).", alt_mbid); cover = Some(path); break; } } } } if cover.is_some() { cover_count += 1; } 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_to_store, fetched_at: now_str(), }; match db::upsert_album_cache(conn, &entry) { Ok(_) => success_count += 1, Err(e) => eprintln!(" [error] Failed to cache: {}", e), } } eprintln!(); eprintln!("Enrichment complete:"); eprintln!(" Albums processed: {}", albums.len()); eprintln!(" Successfully cached: {}", success_count); if fetch_covers { eprintln!(" Covers downloaded: {}", cover_count); } if fetch_genres { eprintln!(" Albums with genres fetched: {}", genre_count); } eprintln!(" Covers directory: {}", covers.display()); } /// Manually pin a MusicBrainz release to an (artist, album) pair. /// /// Fetches genres and cover art for `mbid`, then upserts the result into /// `album_cache`, overwriting any existing entry. Called by `pin-album`. pub fn enrich_by_mbid( conn: &Connection, artist: &str, album: &str, mbid: &str, cover_url_override: Option<&str>, ) { // Reject MBIDs that contain path-traversal sequences or directory // separators — a valid UUID contains only hex digits and hyphens. if mbid.contains("..") || mbid.contains('/') || mbid.contains('\\') { eprintln!( "[error] Invalid MBID '{}': must not contain path components.", mbid ); return; } let client = build_client(); let covers = covers_dir(); eprintln!("Fetching release details for MBID {}...", mbid); let (genre, release_group_id) = fetch_release_details(&client, mbid); if let Some(ref g) = genre { eprintln!(" Genres: {}", g); } let cover = if let Some(url) = cover_url_override { // User supplied a direct image URL — download it using the MBID as the // filename so it fits into the same local cache as CAA covers. // We proceed even if MusicBrainz returned no metadata (genre/rgid): // the user explicitly asked for this cover, so MusicBrainz being // unreachable or the release having no genres is not a reason to bail. if genre.is_none() && release_group_id.is_none() { eprintln!( " [warn] MusicBrainz returned no data for MBID {}. \ Proceeding with cover download only.", mbid ); } eprintln!(" Downloading cover from provided URL..."); let dest = covers.join(format!("{}.jpg", mbid)); let result = try_download(&client, url, &dest); if result.is_some() { eprintln!(" Cover downloaded."); } else { eprintln!(" [warn] Failed to download cover from provided URL."); } result } else { // Without a user-supplied URL we depend entirely on MusicBrainz / // Cover Art Archive. If MB returned nothing the MBID is likely wrong. if genre.is_none() && release_group_id.is_none() { eprintln!( " [error] No data returned for MBID {}. It may be invalid or not found.", mbid ); return; } let result = download_cover_with_fallback( &client, mbid, release_group_id.as_deref(), &covers, false, ); if result.is_some() { eprintln!(" Cover downloaded."); } else { eprintln!(" No cover art available on Cover Art Archive."); } result }; let entry = AlbumCacheEntry { artist: artist.to_string(), album: album.to_string(), musicbrainz_id: Some(mbid.to_string()), cover_url: cover, genre, fetched_at: now_str(), }; match db::upsert_album_cache(conn, &entry) { Ok(_) => eprintln!("Pinned \"{}\" by \"{}\" to MBID {}.", album, artist, mbid), Err(e) => eprintln!("[error] Failed to update cache: {}", e), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn test_urlencoded_simple() { assert_eq!(urlencoded("hello"), "hello"); } #[test] fn test_urlencoded_spaces_and_special() { // Spaces should be encoded as %20. assert_eq!(urlencoded("hello world"), "hello%20world"); // Quotes should be encoded. assert_eq!(urlencoded("\"test\""), "%22test%22"); } #[test] fn test_urlencoded_unicode() { // Unicode characters like † should be percent-encoded byte by byte. let encoded = urlencoded("†"); assert!(encoded.starts_with('%')); // † is U+2020, encoded in UTF-8 as E2 80 A0. assert_eq!(encoded, "%E2%80%A0"); } #[test] fn test_urlencoded_preserves_unreserved() { // RFC 3986 unreserved characters should not be encoded. assert_eq!(urlencoded("A-Z_a-z.0~9"), "A-Z_a-z.0~9"); } #[test] fn test_strip_bracketed_segments() { assert_eq!(strip_bracketed_segments("If (Killing Eve)"), "If"); assert_eq!(strip_bracketed_segments("Album [Deluxe Edition]"), "Album"); assert_eq!(strip_bracketed_segments("Name (Live) [Remaster]"), "Name"); } #[test] fn test_album_search_variants_parenthetical() { let variants = album_search_variants("If (Killing Eve)"); assert_eq!( variants, vec!["If (Killing Eve)".to_string(), "If".to_string()] ); } #[test] fn test_album_search_variants_progressive_shortening() { let variants = album_search_variants("Purple Rain Deluxe"); assert_eq!( variants, vec![ "Purple Rain Deluxe".to_string(), "Purple Rain".to_string(), "Purple".to_string(), ] ); } #[test] fn test_artist_search_variants_parenthetical_alias() { let variants = artist_search_variants("††† (Crosses)"); assert_eq!( variants, vec![ "††† (Crosses)".to_string(), "†††".to_string(), "Crosses".to_string(), ] ); } #[test] fn test_pick_best_itunes_cover_url_prefers_matching_artist_album() { let results = vec![ serde_json::json!({ "artistName": "Amiteve", "collectionName": "The Division Bell - Single", "artworkUrl100": "https://example.com/amiteve/100x100bb.jpg" }), serde_json::json!({ "artistName": "Pink Floyd", "collectionName": "The Division Bell", "artworkUrl100": "https://example.com/pinkfloyd/100x100bb.jpg" }), ]; let chosen = pick_best_itunes_cover_url(&results, "Pink Floyd", "The Division Bell"); assert_eq!( chosen.as_deref(), Some("https://example.com/pinkfloyd/600x600bb.jpg") ); } #[test] fn test_pick_best_itunes_cover_url_rejects_wrong_artist() { let results = vec![serde_json::json!({ "artistName": "Amiteve", "collectionName": "The Division Bell - Single", "artworkUrl100": "https://example.com/amiteve/100x100bb.jpg" })]; let chosen = pick_best_itunes_cover_url(&results, "Pink Floyd", "The Division Bell"); assert!(chosen.is_none()); } #[test] fn test_pick_best_itunes_cover_url_accepts_album_suffix_variant() { let results = vec![serde_json::json!({ "artistName": "Pink Floyd", "collectionName": "The Division Bell - Single", "artworkUrl100": "https://example.com/pinkfloyd-single/100x100bb.jpg" })]; let chosen = pick_best_itunes_cover_url(&results, "Pink Floyd", "The Division Bell"); assert_eq!( chosen.as_deref(), Some("https://example.com/pinkfloyd-single/600x600bb.jpg") ); } #[test] fn test_should_reuse_existing_cover_default_mode() { assert!(should_reuse_existing_cover(true, false)); assert!(!should_reuse_existing_cover(false, false)); } #[test] fn test_should_reuse_existing_cover_force_mode() { assert!(!should_reuse_existing_cover(true, true)); assert!(!should_reuse_existing_cover(false, true)); } #[test] fn test_pick_genre_string_prefers_genres_then_tags() { let genre = pick_genre_string( vec![MbGenre { name: "trip hop".to_string(), }], vec![MbTag { name: "electronic".to_string(), count: 5, }], ); assert_eq!(genre.as_deref(), Some("trip hop")); let from_tags = pick_genre_string( vec![], vec![ MbTag { name: "ambient".to_string(), count: 0, }, MbTag { name: "electronic".to_string(), count: 2, }, ], ); assert_eq!(from_tags.as_deref(), Some("electronic")); } #[test] fn test_uncached_albums_query() { // Verify the uncached_albums query works correctly. let conn = db::open_memory_db().unwrap(); // Insert some scrobbles. db::insert_scrobble( &conn, &db::NewScrobble { artist: "Deftones".into(), album: "White Pony".into(), title: "Digital Bath".into(), track_duration_secs: Some(291), played_duration_secs: 291, scrobbled_at: "2026-03-19T10:00:00".into(), source: "test".into(), }, ) .unwrap(); db::insert_scrobble( &conn, &db::NewScrobble { artist: "††† (Crosses)".into(), album: "††† (Crosses)".into(), title: "Telepathy".into(), track_duration_secs: Some(215), played_duration_secs: 200, scrobbled_at: "2026-03-19T10:05:00".into(), source: "test".into(), }, ) .unwrap(); // Both albums should be uncached initially. let uncached = db::uncached_albums(&conn).unwrap(); assert_eq!(uncached.len(), 2); // Cache one album WITH a cover_url. db::upsert_album_cache( &conn, &db::AlbumCacheEntry { artist: "Deftones".into(), album: "White Pony".into(), musicbrainz_id: Some("test-id".into()), cover_url: Some("/path/to/cover.jpg".into()), genre: Some("alternative metal".into()), fetched_at: "2026-03-19T12:00:00".into(), }, ) .unwrap(); // Now only one album should be uncached. let uncached = db::uncached_albums(&conn).unwrap(); assert_eq!(uncached.len(), 1); assert_eq!(uncached[0].artist, "††† (Crosses)"); // Cache the other album but WITHOUT a cover_url — it should still // appear as "uncached" so enrichment retries the cover download. db::upsert_album_cache( &conn, &db::AlbumCacheEntry { artist: "††† (Crosses)".into(), album: "††† (Crosses)".into(), musicbrainz_id: Some("test-id-2".into()), cover_url: None, genre: None, fetched_at: "2000-01-01T00:00:00".into(), }, ) .unwrap(); // Should still show as uncached because cover_url is NULL. let uncached = db::uncached_albums(&conn).unwrap(); assert_eq!(uncached.len(), 1); assert_eq!(uncached[0].artist, "††† (Crosses)"); } #[test] fn test_upsert_album_cache_replaces() { // Verify that upserting the same (artist, album) replaces the old entry. let conn = db::open_memory_db().unwrap(); let entry1 = AlbumCacheEntry { artist: "Deftones".into(), album: "White Pony".into(), musicbrainz_id: Some("id-1".into()), cover_url: None, genre: Some("rock".into()), fetched_at: "2026-03-19T10:00:00".into(), }; db::upsert_album_cache(&conn, &entry1).unwrap(); // Upsert with different data for the same album. let entry2 = AlbumCacheEntry { artist: "Deftones".into(), album: "White Pony".into(), musicbrainz_id: Some("id-2".into()), cover_url: Some("/path/to/cover.jpg".into()), genre: Some("alternative metal, nu metal".into()), fetched_at: "2026-03-19T12:00:00".into(), }; db::upsert_album_cache(&conn, &entry2).unwrap(); // Should still be only one entry, with the updated data. let count: i64 = conn .query_row("SELECT COUNT(*) FROM album_cache", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 1); // Verify the MBID was updated. let mbid: String = conn .query_row( "SELECT musicbrainz_id FROM album_cache WHERE artist = 'Deftones'", [], |r| r.get(0), ) .unwrap(); assert_eq!(mbid, "id-2"); } #[test] fn test_uncached_albums_respects_retry_cooldown() { let conn = db::open_memory_db().unwrap(); db::insert_scrobble( &conn, &db::NewScrobble { artist: "Unloved".into(), album: "If (Killing Eve)".into(), title: "If".into(), track_duration_secs: Some(180), played_duration_secs: 160, scrobbled_at: "2026-03-19T10:00:00".into(), source: "test".into(), }, ) .unwrap(); // Recent failed lookup should NOT be retried immediately. db::upsert_album_cache( &conn, &db::AlbumCacheEntry { artist: "Unloved".into(), album: "If (Killing Eve)".into(), musicbrainz_id: None, cover_url: None, genre: None, fetched_at: chrono::Local::now() .naive_local() .format("%Y-%m-%dT%H:%M:%S") .to_string(), }, ) .unwrap(); let uncached = db::uncached_albums(&conn).unwrap(); assert!(uncached.is_empty()); } }