Cover repair for MPD

This commit is contained in:
2026-03-23 19:37:48 +00:00
parent ed5682c887
commit d199abdc68
4 changed files with 322 additions and 1 deletions

View File

@@ -271,12 +271,19 @@ scrbblr enrich [OPTIONS]
--online Fetch metadata and covers from MusicBrainz / iTunes / CAA --online Fetch metadata and covers from MusicBrainz / iTunes / CAA
--force Re-fetch all albums from MusicBrainz (implies --online) --force Re-fetch all albums from MusicBrainz (implies --online)
--retry-covers Reset the 7-day cooldown for albums missing covers --retry-covers Reset the 7-day cooldown for albums missing covers
--artist <ARTIST> Limit enrichment to one artist (case-insensitive)
--no-itunes Skip iTunes; fall back directly to Cover Art Archive --no-itunes Skip iTunes; fall back directly to Cover Art Archive
--no-mpd-covers Skip MPD embedded cover extraction --no-mpd-covers Skip MPD embedded cover extraction
--mpd-host <HOST> MPD host for cover extraction [default: localhost] --mpd-host <HOST> MPD host for cover extraction [default: localhost]
--mpd-port <PORT> MPD port for cover extraction [default: 6600] --mpd-port <PORT> MPD port for cover extraction [default: 6600]
--db-path <PATH> Path to the SQLite database --db-path <PATH> Path to the SQLite database
scrbblr repair-mpd-covers [OPTIONS]
--artist <ARTIST> Limit repair to one artist (case-insensitive)
--mpd-host <HOST> MPD host for cover validation [default: localhost]
--mpd-port <PORT> MPD port for cover validation [default: 6600]
--db-path <PATH> Path to the SQLite database
scrbblr last-scrobble [OPTIONS] scrbblr last-scrobble [OPTIONS]
--db-path <PATH> Path to the SQLite database --db-path <PATH> Path to the SQLite database
@@ -348,6 +355,26 @@ Downloaded covers are stored in:
`~/.local/share/scrbblr/covers/` `~/.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`) #### Manually pinning an album (`pin-album`)
Sometimes automatic search fails -- most commonly for classical recordings where Sometimes automatic search fails -- most commonly for classical recordings where

115
src/db.rs
View File

@@ -969,6 +969,46 @@ pub fn albums_without_cover_from_mpd(conn: &Connection) -> Result<Vec<UncachedAl
rows.collect() rows.collect()
} }
/// A cached MPD-local cover entry keyed by `(artist, album)`.
///
/// `cover_url` is expected to point to a local file generated by MPD cover
/// extraction (`covers/mpd_<hash>.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<Vec<MpdLocalCoverAlbum>> {
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`]. /// Cached album metadata returned by [`album_cache_meta`].
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AlbumCacheMeta { pub struct AlbumCacheMeta {
@@ -1761,6 +1801,81 @@ mod tests {
// set_local_cover // 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] #[test]
fn test_set_local_cover_inserts_new_row() { fn test_set_local_cover_inserts_new_row() {
let conn = open_memory_db().unwrap(); let conn = open_memory_db().unwrap();

View File

@@ -1,12 +1,14 @@
//! scrbblr — a local music scrobbler for MPRIS and MPD on Linux. //! 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 //! - `watch` — monitors a player via `playerctl` and/or MPD, recording
//! scrobbles to SQLite. Both sources can run simultaneously. //! scrobbles to SQLite. Both sources can run simultaneously.
//! - `report` — generates listening statistics from the stored data //! - `report` — generates listening statistics from the stored data
//! - `enrich` — fetches album art and genre info from MusicBrainz, //! - `enrich` — fetches album art and genre info from MusicBrainz,
//! and/or extracts embedded covers from MPD //! 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 //! - `last-scrobble`— prints the newest scrobble timestamp
//! - `pin-album` — manually assign a MusicBrainz ID to an album //! - `pin-album` — manually assign a MusicBrainz ID to an album
//! //!
@@ -223,6 +225,28 @@ enum Commands {
#[arg(long)] #[arg(long)]
db_path: Option<String>, db_path: Option<String>,
}, },
/// 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<String>,
/// 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<String>,
},
/// Print the newest scrobble timestamp and exit. /// Print the newest scrobble timestamp and exit.
LastScrobble { LastScrobble {
/// Path to the SQLite database file. Same default as `watch`. /// 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 } => { Commands::LastScrobble { db_path } => {
let path = db_path.unwrap_or_else(default_db_path); let path = db_path.unwrap_or_else(default_db_path);
let conn = match db::open_db(&path) { let conn = match db::open_db(&path) {

View File

@@ -73,6 +73,7 @@ use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@@ -1034,6 +1035,139 @@ pub fn run_mpd_cover_enrich_targeted(
run_mpd_cover_enrich_albums(config, conn, albums); 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 /// Inner loop shared by `run_mpd_cover_enrich` and
/// `run_mpd_cover_enrich_targeted`. Connects to MPD and processes the given /// `run_mpd_cover_enrich_targeted`. Connects to MPD and processes the given
/// album list. /// album list.