Cover repair for MPD
This commit is contained in:
27
README.md
27
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 <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 <HOST> MPD host for cover extraction [default: localhost]
|
||||
--mpd-port <PORT> MPD port for cover extraction [default: 6600]
|
||||
--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]
|
||||
--db-path <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
|
||||
|
||||
115
src/db.rs
115
src/db.rs
@@ -969,6 +969,46 @@ pub fn albums_without_cover_from_mpd(conn: &Connection) -> Result<Vec<UncachedAl
|
||||
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`].
|
||||
#[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();
|
||||
|
||||
47
src/main.rs
47
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<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.
|
||||
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) {
|
||||
|
||||
134
src/mpd.rs
134
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.
|
||||
|
||||
Reference in New Issue
Block a user