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) {