diff --git a/scrbblr-publish.sh b/scrbblr-publish.sh
index 44b4d0e..7c48ae8 100755
--- a/scrbblr-publish.sh
+++ b/scrbblr-publish.sh
@@ -67,6 +67,31 @@ while [[ $# -gt 0 ]]; do
FORCE=1
shift
;;
+ --help|-h)
+ cat <<'EOF'
+Usage: scrbblr-publish [OPTIONS]
+
+Generate and publish the HTML report only when new scrobbles exist.
+Cover art is extracted automatically by `scrbblr report` before each run.
+
+Options:
+ --output
Directory for generated report files (default: ~/music-report)
+ --remote rsync destination, e.g. user@host:/var/www/music-report
+ --db-path Path to scrobbles database (default: auto-detected)
+ --watch Keep running, checking every --interval seconds
+ --interval Seconds between checks in --watch mode (default: 300)
+ --force Regenerate and publish even if no new scrobbles
+ --help, -h Show this help and exit
+
+Config file (loaded before flags):
+ $XDG_CONFIG_HOME/scrbblr/publish.conf (primary)
+ ~/.config/scrbblr/publish.conf (fallback)
+ ~/.scrbblr-publish.conf (legacy)
+
+Supported config variables: OUTPUT_DIR, REMOTE_TARGET, DB_PATH
+EOF
+ exit 0
+ ;;
*)
printf 'Unknown option: %s\n' "$1" >&2
exit 2
diff --git a/src/main.rs b/src/main.rs
index d6a1e2c..136e99b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -143,6 +143,19 @@ enum Commands {
#[arg(long)]
all_time_limit: Option,
+ /// Skip the MPD cover extraction that normally runs before generating
+ /// the report. Useful if MPD is not running or you want a fast run.
+ #[arg(long)]
+ no_enrich: bool,
+
+ /// MPD host for cover extraction (default: localhost).
+ #[arg(long, default_value = "localhost")]
+ mpd_host: String,
+
+ /// MPD port for cover extraction (default: 6600).
+ #[arg(long, default_value = "6600")]
+ mpd_port: u16,
+
/// Path to the SQLite database file. Same default as `watch`.
#[arg(long)]
db_path: Option,
@@ -473,11 +486,13 @@ fn round_to_5(n: i64) -> i64 {
/// Run the `report` subcommand.
///
+/// Runs MPD cover extraction first (unless `--no-enrich`), then generates the report.
+///
/// Three output modes:
/// - **Terminal** (default): queries a single `--period` and prints ASCII tables.
/// - **JSON** (`--json`): same single-period data as pretty-printed JSON.
/// - **HTML** (`--html`): generates a multi-period report (Today / Week / Month /
-/// All Time) with bar charts and album cover art. Auto-runs enrichment first.
+/// All Time) with bar charts and album cover art.
/// With `--output `, writes `index.html` + `covers/` to a directory.
fn run_report(
period: &str,
@@ -486,6 +501,8 @@ fn run_report(
output: Option<&str>,
limit: i64,
all_time_limit: i64,
+ no_enrich: bool,
+ mpd_cfg: &mpd::MpdConfig,
db_path: &str,
) {
let conn = match db::open_db(db_path) {
@@ -512,11 +529,25 @@ fn run_report(
std::process::exit(1);
}
- // For HTML reports, enrich only the albums that will actually appear in
- // the report (across all periods). Uses quiet mode so "nothing to do"
- // isn't printed when everything is already cached.
- if html {
+ // Cover enrichment is only meaningful for HTML reports — terminal and JSON
+ // output never displays cover art, so there is nothing to gain from
+ // extracting or downloading covers for those modes.
+ if html && !no_enrich {
+ // Determine exactly which (artist, album) pairs will appear in the
+ // HTML output across all periods. All enrichment is scoped to this set
+ // so we never fetch covers for albums that won't be shown.
let needed = report::albums_needed_for_report(&conn, limit, all_time_limit);
+
+ // Step 1: Extract embedded covers from MPD (fast, offline, no rate
+ // limits). Only processes albums in `needed` that still have no cover.
+ // Running this before the online step means CAA/iTunes won't waste a
+ // request on an album whose cover is already in the local music file.
+ mpd::run_mpd_cover_enrich_targeted(mpd_cfg, &conn, &needed);
+
+ // Step 2: Online enrichment for albums in `needed` that still have no
+ // cover after the MPD pass (e.g. streams, albums not in the MPD DB).
+ // Respects the 7-day cooldown so we don't hammer MusicBrainz on every
+ // report run.
enrich::run_enrich_targeted(&conn, &needed, true);
}
@@ -652,12 +683,19 @@ fn main() {
output,
limit,
all_time_limit,
+ no_enrich,
+ mpd_host,
+ mpd_port,
db_path,
} => {
let path = db_path.unwrap_or_else(default_db_path);
let atl =
all_time_limit.unwrap_or_else(|| round_to_5((limit as f64 * 2.5).round() as i64));
- run_report(&period, json, html, output.as_deref(), limit, atl, &path);
+ let mpd_cfg = mpd::MpdConfig {
+ host: mpd_host,
+ port: mpd_port,
+ };
+ run_report(&period, json, html, output.as_deref(), limit, atl, no_enrich, &mpd_cfg, &path);
}
Commands::Enrich {
online,
diff --git a/src/mpd.rs b/src/mpd.rs
index a7076c7..fc0639d 100644
--- a/src/mpd.rs
+++ b/src/mpd.rs
@@ -942,7 +942,8 @@ fn try_extract_cover(
/// one; if it finds nothing, the local cover is preserved (see
/// `db::upsert_album_cache` for the `COALESCE` logic).
pub fn run_mpd_cover_enrich(config: &MpdConfig, conn: &Connection) {
- // Find all (artist, album) pairs with scrobbles but no cover yet.
+ // Fetch covers for every scrobbled album that still has no cover_url.
+ // Used by the standalone `enrich` command where the scope is the whole DB.
let albums = match db::albums_without_cover(conn) {
Ok(a) => a,
Err(e) => {
@@ -956,7 +957,54 @@ pub fn run_mpd_cover_enrich(config: &MpdConfig, conn: &Connection) {
return;
}
- // Connect to MPD for this session.
+ run_mpd_cover_enrich_albums(config, conn, albums);
+}
+
+/// Targeted variant: only extract covers for albums that will actually appear
+/// in the report. Avoids fetching covers for the entire DB when only a subset
+/// is needed.
+///
+/// Used by `run_report` so that cover extraction is scoped to the albums the
+/// HTML renderer will actually display.
+pub fn run_mpd_cover_enrich_targeted(
+ config: &MpdConfig,
+ conn: &Connection,
+ needed: &std::collections::HashSet<(String, String)>,
+) {
+ // Start from albums that genuinely have no cover yet, then narrow to the
+ // set the caller cares about. This avoids redundant MPD round-trips for
+ // albums that already have art.
+ let albums = match db::albums_without_cover(conn) {
+ Ok(a) => a,
+ Err(e) => {
+ eprintln!("[error] Failed to query albums without cover: {}", e);
+ return;
+ }
+ };
+
+ let albums: Vec<_> = albums
+ .into_iter()
+ .filter(|a| needed.contains(&(a.artist.clone(), a.album.clone())))
+ .collect();
+
+ if albums.is_empty() {
+ // Quiet: the caller knows this is a background step, no need to
+ // announce that nothing was missing.
+ return;
+ }
+
+ run_mpd_cover_enrich_albums(config, conn, albums);
+}
+
+/// Inner loop shared by `run_mpd_cover_enrich` and
+/// `run_mpd_cover_enrich_targeted`. Connects to MPD and processes the given
+/// album list.
+fn run_mpd_cover_enrich_albums(
+ config: &MpdConfig,
+ conn: &Connection,
+ albums: Vec,
+) {
+ // Connect to MPD once for the whole batch.
let mut mpd_conn = match connect(config) {
Ok(c) => c,
Err(e) => {
@@ -965,12 +1013,6 @@ pub fn run_mpd_cover_enrich(config: &MpdConfig, conn: &Connection) {
}
};
- // Remove the read timeout for cover extraction — readpicture responses can
- // be large and we don't need the idle-loop timeout here.
- // We can't easily change the timeout on a Box, so we just proceed
- // with the existing short timeout but retry reads as needed. In practice,
- // readpicture transfers are fast on a local socket.
-
let covers = enrich::covers_dir();
eprintln!(
"Extracting covers from MPD for {} album(s)...",
@@ -989,7 +1031,8 @@ pub fn run_mpd_cover_enrich(config: &MpdConfig, conn: &Connection) {
album.album
);
- // Step 1: Ask MPD for a file from this album.
+ // Step 1: Ask MPD for any file from this album so we have a path to
+ // hand to readpicture.
let file_uri = match search_song_for_album(&mut mpd_conn, &album.artist, &album.album) {
Some(f) => f,
None => {
@@ -1010,8 +1053,8 @@ pub fn run_mpd_cover_enrich(config: &MpdConfig, conn: &Connection) {
}
};
- // Step 3: Resize and re-encode the image to stay consistent with
- // covers downloaded from the Cover Art Archive.
+ // Step 3: Resize and re-encode to stay consistent with covers
+ // downloaded from the Cover Art Archive.
let processed = match enrich::resize_cover_bytes(&picture_bytes) {
Some(b) => b,
None => {
@@ -1030,7 +1073,7 @@ pub fn run_mpd_cover_enrich(config: &MpdConfig, conn: &Connection) {
continue;
}
- // Step 5: Record the local path in album_cache so the report generator
+ // Step 5: Record the local path in album_cache so the HTML renderer
// and the online enrich command can find it.
let cover_path = dest.to_string_lossy().to_string();
match db::set_local_cover(conn, &album.artist, &album.album, &cover_path) {