From d199abdc68da473eccc37671bb4140641894b960 Mon Sep 17 00:00:00 2001 From: Artur Meski Date: Mon, 23 Mar 2026 19:37:48 +0000 Subject: [PATCH] Cover repair for MPD --- README.md | 27 +++++++++++ src/db.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 47 +++++++++++++++++- src/mpd.rs | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d6e4079..4f40b21 100644 --- a/README.md +++ b/README.md @@ -271,12 +271,19 @@ scrbblr enrich [OPTIONS] --online Fetch metadata and covers from MusicBrainz / iTunes / CAA --force Re-fetch all albums from MusicBrainz (implies --online) --retry-covers Reset the 7-day cooldown for albums missing covers + --artist Limit enrichment to one artist (case-insensitive) --no-itunes Skip iTunes; fall back directly to Cover Art Archive --no-mpd-covers Skip MPD embedded cover extraction --mpd-host MPD host for cover extraction [default: localhost] --mpd-port MPD port for cover extraction [default: 6600] --db-path Path to the SQLite database +scrbblr repair-mpd-covers [OPTIONS] + --artist Limit repair to one artist (case-insensitive) + --mpd-host MPD host for cover validation [default: localhost] + --mpd-port MPD port for cover validation [default: 6600] + --db-path Path to the SQLite database + scrbblr last-scrobble [OPTIONS] --db-path Path to the SQLite database @@ -348,6 +355,26 @@ Downloaded covers are stored in: `~/.local/share/scrbblr/covers/` +#### Repairing suspect MPD covers (`repair-mpd-covers`) + +If you already have cached MPD-local covers (`mpd_*.jpg`) and suspect some are +incomplete/corrupted, run: + +```bash +scrbblr repair-mpd-covers +``` + +This command re-extracts embedded art from MPD for albums with MPD-local cover +paths in `album_cache`, applies the same resize/re-encode pipeline, and +compares bytes with the existing file. Missing or mismatched files are +automatically overwritten. + +Target one artist if needed: + +```bash +scrbblr repair-mpd-covers --artist "Deftones" +``` + #### Manually pinning an album (`pin-album`) Sometimes automatic search fails -- most commonly for classical recordings where diff --git a/src/db.rs b/src/db.rs index c43cc31..35f8401 100644 --- a/src/db.rs +++ b/src/db.rs @@ -969,6 +969,46 @@ pub fn albums_without_cover_from_mpd(conn: &Connection) -> Result.jpg`). +#[derive(Debug, Clone)] +pub struct MpdLocalCoverAlbum { + pub artist: String, + pub album: String, + pub cover_url: String, +} + +/// Find MPD-sourced albums that currently point to a local MPD cover file. +/// +/// Used by the `repair-mpd-covers` command to revalidate and, if needed, +/// re-fetch previously cached covers. +/// +/// We only include rows where: +/// +/// - at least one scrobble came from MPD (`source = 'MPD'`), and +/// - `album_cache.cover_url` is a non-NULL path containing `mpd_`. +pub fn albums_with_local_mpd_cover(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT DISTINCT s.artist, s.album, c.cover_url + FROM scrobbles s + JOIN album_cache c ON s.artist = c.artist AND s.album = c.album + WHERE s.source = 'MPD' + AND c.cover_url IS NOT NULL + AND c.cover_url LIKE '%mpd_%' + ORDER BY s.artist, s.album", + )?; + let rows = stmt.query_map([], |row| { + Ok(MpdLocalCoverAlbum { + artist: row.get(0)?, + album: row.get(1)?, + cover_url: row.get(2)?, + }) + })?; + rows.collect() +} + /// Cached album metadata returned by [`album_cache_meta`]. #[derive(Debug, Clone)] pub struct AlbumCacheMeta { @@ -1761,6 +1801,81 @@ mod tests { // set_local_cover // ----------------------------------------------------------------------- + #[test] + fn test_albums_with_local_mpd_cover_returns_only_mpd_local_rows() { + let conn = open_memory_db().unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + track_duration_secs: Some(291), + played_duration_secs: 200, + scrobbled_at: today_at("10:00:00"), + source: "MPD".into(), + }, + ) + .unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "Tourist".into(), + album: "Inside Out".into(), + title: "Inside Out".into(), + track_duration_secs: Some(240), + played_duration_secs: 200, + scrobbled_at: today_at("10:05:00"), + source: "MPD".into(), + }, + ) + .unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "Bonobo".into(), + album: "Black Sands".into(), + title: "Kiara".into(), + track_duration_secs: Some(220), + played_duration_secs: 200, + scrobbled_at: today_at("10:10:00"), + source: "Qobuz".into(), + }, + ) + .unwrap(); + + set_local_cover( + &conn, + "Deftones", + "White Pony", + "/tmp/scrbblr/covers/mpd_deadbeef.jpg", + ) + .unwrap(); + set_local_cover( + &conn, + "Tourist", + "Inside Out", + "/tmp/scrbblr/covers/itunes_123.jpg", + ) + .unwrap(); + set_local_cover( + &conn, + "Bonobo", + "Black Sands", + "/tmp/scrbblr/covers/mpd_abcdef01.jpg", + ) + .unwrap(); + + let rows = albums_with_local_mpd_cover(&conn).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].artist, "Deftones"); + assert_eq!(rows[0].album, "White Pony"); + assert!(rows[0].cover_url.contains("mpd_")); + } + #[test] fn test_set_local_cover_inserts_new_row() { let conn = open_memory_db().unwrap(); diff --git a/src/main.rs b/src/main.rs index 24cceb4..d183fb5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,14 @@ //! scrbblr — a local music scrobbler for MPRIS and MPD on Linux. //! -//! This is the CLI entry point. It provides five subcommands: +//! This is the CLI entry point. It provides six subcommands: //! //! - `watch` — monitors a player via `playerctl` and/or MPD, recording //! scrobbles to SQLite. Both sources can run simultaneously. //! - `report` — generates listening statistics from the stored data //! - `enrich` — fetches album art and genre info from MusicBrainz, //! and/or extracts embedded covers from MPD +//! - `repair-mpd-covers` — revalidate MPD-local cached covers and repair +//! any mismatches by re-fetching from MPD //! - `last-scrobble`— prints the newest scrobble timestamp //! - `pin-album` — manually assign a MusicBrainz ID to an album //! @@ -223,6 +225,28 @@ enum Commands { #[arg(long)] db_path: Option, }, + /// Revalidate cached MPD-local covers and automatically repair mismatches. + /// + /// This checks albums whose `cover_url` points to an MPD-generated local + /// file (`mpd_*.jpg`), re-extracts embedded art from MPD, and compares the + /// processed bytes. Missing or mismatched files are overwritten. + RepairMpdCovers { + /// Limit revalidation to albums by this artist (case-insensitive match). + #[arg(long)] + artist: Option, + + /// MPD server hostname, IP address, or Unix socket path. + #[arg(long, default_value = "localhost")] + mpd_host: String, + + /// MPD server TCP port. Ignored for Unix socket paths. + #[arg(long, default_value = "6600")] + mpd_port: u16, + + /// Path to the SQLite database file. Same default as `watch`. + #[arg(long)] + db_path: Option, + }, /// Print the newest scrobble timestamp and exit. LastScrobble { /// Path to the SQLite database file. Same default as `watch`. @@ -821,6 +845,27 @@ fn main() { } } } + Commands::RepairMpdCovers { + artist, + mpd_host, + mpd_port, + db_path, + } => { + let path = db_path.unwrap_or_else(default_db_path); + let conn = match db::open_db(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to open database at {}: {}", path, e); + std::process::exit(1); + } + }; + + let mpd_cfg = mpd::MpdConfig { + host: mpd_host, + port: mpd_port, + }; + mpd::run_mpd_cover_revalidate(&mpd_cfg, &conn, artist.as_deref()); + } Commands::LastScrobble { db_path } => { let path = db_path.unwrap_or_else(default_db_path); let conn = match db::open_db(&path) { diff --git a/src/mpd.rs b/src/mpd.rs index 8e0b289..e829f93 100644 --- a/src/mpd.rs +++ b/src/mpd.rs @@ -73,6 +73,7 @@ use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::TcpStream; #[cfg(unix)] use std::os::unix::net::UnixStream; +use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -1034,6 +1035,139 @@ pub fn run_mpd_cover_enrich_targeted( run_mpd_cover_enrich_albums(config, conn, albums); } +/// Revalidate already-cached local MPD covers and re-fetch mismatches. +/// +/// This command targets albums that already have `cover_url` pointing to an +/// MPD-local path (`mpd_*.jpg`). For each candidate album it re-reads embedded +/// art from MPD, applies the same resize/re-encode pipeline, then compares the +/// resulting bytes to the existing file. +/// +/// If bytes differ (or the file is missing), the local file is overwritten and +/// `album_cache.fetched_at` is refreshed via [`db::set_local_cover`]. +pub fn run_mpd_cover_revalidate(config: &MpdConfig, conn: &Connection, artist: Option<&str>) { + let mut albums = match db::albums_with_local_mpd_cover(conn) { + Ok(a) => a, + Err(e) => { + eprintln!("[error] Failed to query MPD-local covers: {}", e); + return; + } + }; + + if let Some(artist_name) = artist { + albums.retain(|a| a.artist.eq_ignore_ascii_case(artist_name)); + } + + if albums.is_empty() { + if let Some(artist_name) = artist { + eprintln!( + "No MPD-local covers found for artist \"{}\". Nothing to revalidate.", + artist_name + ); + } else { + eprintln!("No MPD-local covers found. Nothing to revalidate."); + } + return; + } + + let mut mpd_conn = match connect(config) { + Ok(c) => c, + Err(e) => { + eprintln!("[warn] MPD unreachable, skipping cover revalidation: {}", e); + return; + } + }; + + eprintln!("Revalidating {} MPD-local cover(s)...", albums.len()); + + let mut unchanged = 0; + let mut repaired = 0; + let mut skipped = 0; + + for (i, album) in albums.iter().enumerate() { + eprintln!( + "[{}/{}] {} - {}", + i + 1, + albums.len(), + album.artist, + album.album + ); + + let file_uri = match search_song_for_album(&mut mpd_conn, &album.artist, &album.album) { + Some(f) => f, + None => { + eprintln!(" Not found in MPD database, skipping."); + skipped += 1; + continue; + } + }; + + let picture_bytes = match read_picture(&mut mpd_conn, &file_uri) { + Some(b) => b, + None => { + eprintln!(" No embedded cover art found, skipping."); + skipped += 1; + continue; + } + }; + + let processed = match enrich::resize_cover_bytes(&picture_bytes) { + Some(b) => b, + None => { + eprintln!(" [warn] Could not decode embedded cover, skipping."); + skipped += 1; + continue; + } + }; + + let dest = Path::new(&album.cover_url); + let needs_repair = match std::fs::read(dest) { + Ok(existing) => existing != processed, + Err(_) => true, + }; + + if !needs_repair { + eprintln!(" OK: existing file matches MPD extraction."); + unchanged += 1; + continue; + } + + if let Some(parent) = dest.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + eprintln!(" [warn] Failed to create parent directory: {}", e); + skipped += 1; + continue; + } + + if let Err(e) = std::fs::write(dest, &processed) { + eprintln!(" [warn] Failed to write repaired cover: {}", e); + skipped += 1; + continue; + } + + match db::set_local_cover(conn, &album.artist, &album.album, &album.cover_url) { + Ok(_) => { + eprintln!(" Repaired: {}", album.cover_url); + repaired += 1; + } + Err(e) => { + eprintln!( + " [error] Repaired file but failed to update album_cache: {}", + e + ); + skipped += 1; + } + } + } + + eprintln!(); + eprintln!("MPD cover revalidation complete:"); + eprintln!(" Albums checked: {}", albums.len()); + eprintln!(" Covers unchanged: {}", unchanged); + eprintln!(" Covers repaired: {}", repaired); + eprintln!(" Skipped: {}", skipped); +} + /// Inner loop shared by `run_mpd_cover_enrich` and /// `run_mpd_cover_enrich_targeted`. Connects to MPD and processes the given /// album list.