Support for MPD
This commit is contained in:
253
src/db.rs
253
src/db.rs
@@ -706,12 +706,29 @@ pub struct AlbumCacheEntry {
|
|||||||
pub fetched_at: String,
|
pub fetched_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert or update an album_cache entry. Uses INSERT OR REPLACE so that
|
/// Insert or update an album_cache entry.
|
||||||
/// re-running `enrich` on an already-cached album updates the data.
|
///
|
||||||
|
/// Uses `ON CONFLICT DO UPDATE` (SQLite upsert syntax) rather than
|
||||||
|
/// `INSERT OR REPLACE` so that a locally-extracted MPD cover is not
|
||||||
|
/// silently discarded when the online enrichment runs later.
|
||||||
|
///
|
||||||
|
/// Specifically, `cover_url` is updated with `COALESCE(new, existing)`:
|
||||||
|
/// if the incoming entry has no cover URL (the online enrichment found
|
||||||
|
/// nothing on the Cover Art Archive), the previously stored local cover
|
||||||
|
/// (e.g. one extracted from MPD via `readpicture`) is preserved. If the
|
||||||
|
/// incoming entry has a cover URL, it takes precedence.
|
||||||
|
///
|
||||||
|
/// All other fields (`musicbrainz_id`, `genre`, `fetched_at`) are always
|
||||||
|
/// overwritten with the new values.
|
||||||
pub fn upsert_album_cache(conn: &Connection, entry: &AlbumCacheEntry) -> Result<()> {
|
pub fn upsert_album_cache(conn: &Connection, entry: &AlbumCacheEntry) -> Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO album_cache (artist, album, musicbrainz_id, cover_url, genre, fetched_at)
|
"INSERT INTO album_cache (artist, album, musicbrainz_id, cover_url, genre, fetched_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT(artist, album) DO UPDATE SET
|
||||||
|
musicbrainz_id = excluded.musicbrainz_id,
|
||||||
|
cover_url = COALESCE(excluded.cover_url, cover_url),
|
||||||
|
genre = excluded.genre,
|
||||||
|
fetched_at = excluded.fetched_at",
|
||||||
params![
|
params![
|
||||||
entry.artist,
|
entry.artist,
|
||||||
entry.album,
|
entry.album,
|
||||||
@@ -724,6 +741,67 @@ pub fn upsert_album_cache(conn: &Connection, entry: &AlbumCacheEntry) -> Result<
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a locally-extracted cover art path for an (artist, album) pair.
|
||||||
|
///
|
||||||
|
/// Unlike [`upsert_album_cache`], this function only writes `cover_url`
|
||||||
|
/// (and `fetched_at`), leaving `musicbrainz_id` and `genre` unchanged if a
|
||||||
|
/// cache row already exists, or NULL if this is the first time we're seeing
|
||||||
|
/// this album. It is used by the MPD cover extractor to cache cover art from
|
||||||
|
/// embedded file tags before a MusicBrainz lookup has been performed.
|
||||||
|
///
|
||||||
|
/// A subsequent call to `upsert_album_cache` (from the online enrichment)
|
||||||
|
/// will fill in `musicbrainz_id` and `genre` while preserving this cover URL
|
||||||
|
/// if no better one is found online.
|
||||||
|
pub fn set_local_cover(
|
||||||
|
conn: &Connection,
|
||||||
|
artist: &str,
|
||||||
|
album: &str,
|
||||||
|
cover_path: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let fetched_at = chrono::Local::now()
|
||||||
|
.naive_local()
|
||||||
|
.format("%Y-%m-%dT%H:%M:%S")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO album_cache (artist, album, cover_url, fetched_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(artist, album) DO UPDATE SET
|
||||||
|
cover_url = ?3,
|
||||||
|
fetched_at = ?4",
|
||||||
|
params![artist, album, cover_path, fetched_at],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find all (artist, album) pairs that have scrobbles but no `cover_url`
|
||||||
|
/// in `album_cache` yet.
|
||||||
|
///
|
||||||
|
/// Used by the MPD cover extractor to determine which albums need a cover.
|
||||||
|
/// Albums whose `album_cache` row already has a non-NULL `cover_url` are
|
||||||
|
/// excluded even if `musicbrainz_id` or `genre` are missing — the cover
|
||||||
|
/// is the only thing the extractor cares about.
|
||||||
|
///
|
||||||
|
/// Albums with an empty `album` field are excluded (these are typically
|
||||||
|
/// singles or radio streams without proper album tags).
|
||||||
|
pub fn albums_without_cover(conn: &Connection) -> Result<Vec<UncachedAlbum>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT DISTINCT s.artist, s.album
|
||||||
|
FROM scrobbles s
|
||||||
|
LEFT JOIN album_cache c ON s.artist = c.artist AND s.album = c.album
|
||||||
|
WHERE s.album != ''
|
||||||
|
AND (c.id IS NULL OR c.cover_url IS NULL)
|
||||||
|
ORDER BY s.artist, s.album",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(UncachedAlbum {
|
||||||
|
artist: row.get(0)?,
|
||||||
|
album: row.get(1)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
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 {
|
||||||
@@ -1159,7 +1237,11 @@ mod tests {
|
|||||||
// Without any album_cache entry, both (artist, album) pairs are
|
// Without any album_cache entry, both (artist, album) pairs are
|
||||||
// separate groups — pre-fix behaviour reproduced here.
|
// separate groups — pre-fix behaviour reproduced here.
|
||||||
let albums_no_cache = top_albums(&conn, "all", 10).unwrap();
|
let albums_no_cache = top_albums(&conn, "all", 10).unwrap();
|
||||||
assert_eq!(albums_no_cache.len(), 2, "without cache: two separate groups");
|
assert_eq!(
|
||||||
|
albums_no_cache.len(),
|
||||||
|
2,
|
||||||
|
"without cache: two separate groups"
|
||||||
|
);
|
||||||
|
|
||||||
// Pin the album via album_cache for "Choir". The MBID lookup in
|
// Pin the album via album_cache for "Choir". The MBID lookup in
|
||||||
// top_albums will now resolve "Soloist"'s rows to the same MBID.
|
// top_albums will now resolve "Soloist"'s rows to the same MBID.
|
||||||
@@ -1377,4 +1459,165 @@ mod tests {
|
|||||||
Some("covers/new.jpg")
|
Some("covers/new.jpg")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// albums_without_cover
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_albums_without_cover_returns_uncovered() {
|
||||||
|
let conn = open_memory_db().unwrap();
|
||||||
|
seed_db(&conn);
|
||||||
|
|
||||||
|
// No cache entries yet — all albums should appear.
|
||||||
|
let albums = albums_without_cover(&conn).unwrap();
|
||||||
|
// seed_db inserts scrobbles for two distinct albums:
|
||||||
|
// "††† (Crosses)" and "White Pony"
|
||||||
|
let names: Vec<&str> = albums.iter().map(|a| a.album.as_str()).collect();
|
||||||
|
assert!(names.contains(&"††† (Crosses)"), "got: {:?}", names);
|
||||||
|
assert!(names.contains(&"White Pony"), "got: {:?}", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_albums_without_cover_excludes_covered() {
|
||||||
|
let conn = open_memory_db().unwrap();
|
||||||
|
seed_db(&conn);
|
||||||
|
|
||||||
|
// Give "White Pony" a cover.
|
||||||
|
set_local_cover(&conn, "Deftones", "White Pony", "covers/wp.jpg").unwrap();
|
||||||
|
|
||||||
|
let albums = albums_without_cover(&conn).unwrap();
|
||||||
|
let names: Vec<&str> = albums.iter().map(|a| a.album.as_str()).collect();
|
||||||
|
|
||||||
|
// Only the uncovered album should remain.
|
||||||
|
assert!(!names.contains(&"White Pony"), "got: {:?}", names);
|
||||||
|
assert!(names.contains(&"††† (Crosses)"), "got: {:?}", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// set_local_cover
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_local_cover_inserts_new_row() {
|
||||||
|
let conn = open_memory_db().unwrap();
|
||||||
|
seed_db(&conn);
|
||||||
|
|
||||||
|
// Album has no cache row yet.
|
||||||
|
set_local_cover(&conn, "Deftones", "White Pony", "covers/wp.jpg").unwrap();
|
||||||
|
|
||||||
|
let meta = album_cache_meta(&conn, "Deftones", "White Pony")
|
||||||
|
.unwrap()
|
||||||
|
.expect("cache row should exist");
|
||||||
|
assert_eq!(meta.cover_url.as_deref(), Some("covers/wp.jpg"));
|
||||||
|
// musicbrainz_id and genre should be NULL (not set by set_local_cover).
|
||||||
|
assert!(meta.mbid.is_none());
|
||||||
|
assert!(meta.genre.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_local_cover_updates_existing_row() {
|
||||||
|
let conn = open_memory_db().unwrap();
|
||||||
|
seed_db(&conn);
|
||||||
|
|
||||||
|
// Seed an existing cache entry with a genre but no cover.
|
||||||
|
upsert_album_cache(
|
||||||
|
&conn,
|
||||||
|
&AlbumCacheEntry {
|
||||||
|
artist: "Deftones".into(),
|
||||||
|
album: "White Pony".into(),
|
||||||
|
musicbrainz_id: Some("some-mbid".into()),
|
||||||
|
cover_url: None,
|
||||||
|
genre: Some("nu-metal".into()),
|
||||||
|
fetched_at: "2026-01-01T00:00:00".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Now set the cover via MPD extraction.
|
||||||
|
set_local_cover(&conn, "Deftones", "White Pony", "covers/wp_local.jpg").unwrap();
|
||||||
|
|
||||||
|
let meta = album_cache_meta(&conn, "Deftones", "White Pony")
|
||||||
|
.unwrap()
|
||||||
|
.expect("cache row should exist");
|
||||||
|
// Cover is updated.
|
||||||
|
assert_eq!(meta.cover_url.as_deref(), Some("covers/wp_local.jpg"));
|
||||||
|
// The existing mbid and genre must be preserved — set_local_cover
|
||||||
|
// must not overwrite them.
|
||||||
|
assert_eq!(meta.mbid.as_deref(), Some("some-mbid"));
|
||||||
|
assert_eq!(meta.genre.as_deref(), Some("nu-metal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// upsert_album_cache — COALESCE cover_url preservation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upsert_preserves_local_cover_when_online_finds_none() {
|
||||||
|
let conn = open_memory_db().unwrap();
|
||||||
|
seed_db(&conn);
|
||||||
|
|
||||||
|
// Step 1: MPD cover extractor sets a local cover.
|
||||||
|
set_local_cover(&conn, "Deftones", "White Pony", "covers/wp_local.jpg").unwrap();
|
||||||
|
|
||||||
|
// Step 2: Online enrichment finds the MBID and genre, but no CAA cover.
|
||||||
|
upsert_album_cache(
|
||||||
|
&conn,
|
||||||
|
&AlbumCacheEntry {
|
||||||
|
artist: "Deftones".into(),
|
||||||
|
album: "White Pony".into(),
|
||||||
|
musicbrainz_id: Some("abc-123".into()),
|
||||||
|
cover_url: None, // no cover from CAA
|
||||||
|
genre: Some("nu-metal, alternative metal".into()),
|
||||||
|
fetched_at: "2026-03-21T12:00:00".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The local cover must be preserved because COALESCE(NULL, existing) = existing.
|
||||||
|
let meta = album_cache_meta(&conn, "Deftones", "White Pony")
|
||||||
|
.unwrap()
|
||||||
|
.expect("cache row should exist");
|
||||||
|
assert_eq!(
|
||||||
|
meta.cover_url.as_deref(),
|
||||||
|
Some("covers/wp_local.jpg"),
|
||||||
|
"local cover should be preserved when online enrichment finds nothing"
|
||||||
|
);
|
||||||
|
// Genre and MBID should be updated.
|
||||||
|
assert_eq!(meta.mbid.as_deref(), Some("abc-123"));
|
||||||
|
assert!(meta.genre.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upsert_replaces_local_cover_when_online_finds_one() {
|
||||||
|
let conn = open_memory_db().unwrap();
|
||||||
|
seed_db(&conn);
|
||||||
|
|
||||||
|
// Step 1: MPD cover extractor sets a local cover.
|
||||||
|
set_local_cover(&conn, "Deftones", "White Pony", "covers/wp_local.jpg").unwrap();
|
||||||
|
|
||||||
|
// Step 2: Online enrichment finds a cover from the Cover Art Archive.
|
||||||
|
upsert_album_cache(
|
||||||
|
&conn,
|
||||||
|
&AlbumCacheEntry {
|
||||||
|
artist: "Deftones".into(),
|
||||||
|
album: "White Pony".into(),
|
||||||
|
musicbrainz_id: Some("abc-123".into()),
|
||||||
|
cover_url: Some("covers/abc-123.jpg".into()), // has CAA cover
|
||||||
|
genre: Some("nu-metal".into()),
|
||||||
|
fetched_at: "2026-03-21T12:00:00".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The CAA cover takes priority over the local one.
|
||||||
|
let meta = album_cache_meta(&conn, "Deftones", "White Pony")
|
||||||
|
.unwrap()
|
||||||
|
.expect("cache row should exist");
|
||||||
|
assert_eq!(
|
||||||
|
meta.cover_url.as_deref(),
|
||||||
|
Some("covers/abc-123.jpg"),
|
||||||
|
"CAA cover should replace the local cover"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,7 +309,10 @@ fn search_releases(client: &Client, artist: &str, album: &str) -> Vec<String> {
|
|||||||
// returning wrong results at score 85.
|
// returning wrong results at score 85.
|
||||||
for (idx, variant) in album_variants.iter().take(2).enumerate() {
|
for (idx, variant) in album_variants.iter().take(2).enumerate() {
|
||||||
if idx > 0 {
|
if idx > 0 {
|
||||||
eprintln!(" Retrying title-only with stripped name: \"{}\"...", variant);
|
eprintln!(
|
||||||
|
" Retrying title-only with stripped name: \"{}\"...",
|
||||||
|
variant
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" Retrying with title-only search (no artist constraint)...");
|
eprintln!(" Retrying with title-only search (no artist constraint)...");
|
||||||
}
|
}
|
||||||
@@ -611,14 +614,50 @@ fn fetch_release_group_genre(client: &Client, release_group_id: &str) -> Option<
|
|||||||
/// language names, format codes) that are meaningless as genre labels.
|
/// language names, format codes) that are meaningless as genre labels.
|
||||||
const TAG_BLOCKLIST: &[&str] = &[
|
const TAG_BLOCKLIST: &[&str] = &[
|
||||||
// Languages
|
// Languages
|
||||||
"english", "french", "german", "spanish", "italian", "portuguese",
|
"english",
|
||||||
"japanese", "korean", "chinese", "russian", "swedish", "norwegian",
|
"french",
|
||||||
"danish", "dutch", "polish", "finnish", "czech", "hungarian", "turkish",
|
"german",
|
||||||
"arabic", "hebrew", "greek", "romanian", "ukrainian", "catalan",
|
"spanish",
|
||||||
|
"italian",
|
||||||
|
"portuguese",
|
||||||
|
"japanese",
|
||||||
|
"korean",
|
||||||
|
"chinese",
|
||||||
|
"russian",
|
||||||
|
"swedish",
|
||||||
|
"norwegian",
|
||||||
|
"danish",
|
||||||
|
"dutch",
|
||||||
|
"polish",
|
||||||
|
"finnish",
|
||||||
|
"czech",
|
||||||
|
"hungarian",
|
||||||
|
"turkish",
|
||||||
|
"arabic",
|
||||||
|
"hebrew",
|
||||||
|
"greek",
|
||||||
|
"romanian",
|
||||||
|
"ukrainian",
|
||||||
|
"catalan",
|
||||||
// Technical / format descriptors
|
// Technical / format descriptors
|
||||||
"isrc", "cd-text", "asin", "barcode", "album", "single", "ep",
|
"isrc",
|
||||||
"compilation", "soundtrack", "live", "instrumental", "digital",
|
"cd-text",
|
||||||
"remaster", "remastered", "deluxe", "bonus", "stereo", "mono",
|
"asin",
|
||||||
|
"barcode",
|
||||||
|
"album",
|
||||||
|
"single",
|
||||||
|
"ep",
|
||||||
|
"compilation",
|
||||||
|
"soundtrack",
|
||||||
|
"live",
|
||||||
|
"instrumental",
|
||||||
|
"digital",
|
||||||
|
"remaster",
|
||||||
|
"remastered",
|
||||||
|
"deluxe",
|
||||||
|
"bonus",
|
||||||
|
"stereo",
|
||||||
|
"mono",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Return true if a MusicBrainz tag looks like a real genre label.
|
/// Return true if a MusicBrainz tag looks like a real genre label.
|
||||||
@@ -704,7 +743,7 @@ fn download_cover_with_fallback(
|
|||||||
/// Returns the processed JPEG bytes, or `None` if decoding or encoding fails.
|
/// Returns the processed JPEG bytes, or `None` if decoding or encoding fails.
|
||||||
/// Images already within the size limit are still re-encoded to JPEG so that
|
/// Images already within the size limit are still re-encoded to JPEG so that
|
||||||
/// format is consistent regardless of what the Cover Art Archive sent.
|
/// format is consistent regardless of what the Cover Art Archive sent.
|
||||||
fn resize_cover_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
|
pub fn resize_cover_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||||
let img = image::load_from_memory(bytes).ok()?;
|
let img = image::load_from_memory(bytes).ok()?;
|
||||||
|
|
||||||
// Downscale only if the image exceeds the target dimension on either axis.
|
// Downscale only if the image exceeds the target dimension on either axis.
|
||||||
@@ -1010,10 +1049,7 @@ pub fn enrich_by_mbid(
|
|||||||
};
|
};
|
||||||
|
|
||||||
match db::upsert_album_cache(conn, &entry) {
|
match db::upsert_album_cache(conn, &entry) {
|
||||||
Ok(_) => eprintln!(
|
Ok(_) => eprintln!("Pinned \"{}\" by \"{}\" to MBID {}.", album, artist, mbid),
|
||||||
"Pinned \"{}\" by \"{}\" to MBID {}.",
|
|
||||||
album, artist, mbid
|
|
||||||
),
|
|
||||||
Err(e) => eprintln!("[error] Failed to update cache: {}", e),
|
Err(e) => eprintln!("[error] Failed to update cache: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
396
src/main.rs
396
src/main.rs
@@ -1,15 +1,19 @@
|
|||||||
//! MPRIS Scrobbler — a local music scrobbler for MPRIS-compatible players.
|
//! MPRIS + MPD Scrobbler — a local music scrobbler for Linux.
|
||||||
//!
|
//!
|
||||||
//! This is the CLI entry point. It provides four subcommands:
|
//! This is the CLI entry point. It provides five subcommands:
|
||||||
//!
|
//!
|
||||||
//! - `watch` — monitors a player via `playerctl` and records scrobbles to SQLite
|
//! - `watch` — monitors a player via `playerctl` and/or MPD, recording
|
||||||
//! - `report` — generates listening statistics from the stored scrobble data
|
//! scrobbles to SQLite. Both sources can run simultaneously.
|
||||||
//! - `enrich` — fetches album art and genre info from MusicBrainz
|
//! - `report` — generates listening statistics from the stored data
|
||||||
//! - `last-scrobble` — prints the newest scrobble timestamp
|
//! - `enrich` — fetches album art and genre info from MusicBrainz,
|
||||||
//! - `pin-album` — manually assign a MusicBrainz ID to an album the automatic search missed
|
//! and/or extracts embedded covers from MPD
|
||||||
|
//! - `last-scrobble`— prints the newest scrobble timestamp
|
||||||
|
//! - `pin-album` — manually assign a MusicBrainz ID to an album
|
||||||
//!
|
//!
|
||||||
//! ## Architecture overview
|
//! ## Architecture overview
|
||||||
//!
|
//!
|
||||||
|
//! ### MPRIS watcher
|
||||||
|
//!
|
||||||
//! The `watch` command spawns two `playerctl --follow` child processes:
|
//! The `watch` command spawns two `playerctl --follow` child processes:
|
||||||
//!
|
//!
|
||||||
//! 1. **Metadata follower** — emits a line each time the track changes,
|
//! 1. **Metadata follower** — emits a line each time the track changes,
|
||||||
@@ -28,12 +32,24 @@
|
|||||||
//! [playerctl status] ──reader thread──→ ┘
|
//! [playerctl status] ──reader thread──→ ┘
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Ctrl+C triggers a graceful shutdown: the handler sends an `Eof` event
|
//! ### MPD watcher (optional, enabled with `--mpd`)
|
||||||
//! through the channel, causing the tracker to evaluate the last track
|
//!
|
||||||
//! before exiting.
|
//! When `--mpd` is passed, a separate thread is spawned that connects to MPD
|
||||||
|
//! using the idle protocol. It maintains its own `ScrobbleTracker` and writes
|
||||||
|
//! to the same database. The two watchers are fully independent — neither
|
||||||
|
//! knows about the other, and no synchronisation is needed between them.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! [MPD idle player] ──mpd thread──→ ScrobbleTracker → SQLite (same DB)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Ctrl+C triggers graceful shutdown of both watchers: the MPRIS watcher
|
||||||
|
//! receives an `Eof` event through its channel, and the MPD watcher notices
|
||||||
|
//! the shared `running` flag becoming false on its next idle timeout.
|
||||||
|
|
||||||
mod db;
|
mod db;
|
||||||
mod enrich;
|
mod enrich;
|
||||||
|
mod mpd;
|
||||||
mod report;
|
mod report;
|
||||||
mod watcher;
|
mod watcher;
|
||||||
|
|
||||||
@@ -61,13 +77,38 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Watch playerctl metadata and scrobble tracks to the local database.
|
/// Watch playerctl metadata and MPD, scrobbling tracks to the local database.
|
||||||
|
///
|
||||||
|
/// Both watchers are active by default. Use `--no-mpris` or `--no-mpd` to
|
||||||
|
/// disable one of them. Each maintains its own ScrobbleTracker and writes
|
||||||
|
/// directly to the same SQLite database.
|
||||||
Watch {
|
Watch {
|
||||||
/// Player name for playerctl (the MPRIS bus name).
|
/// MPRIS player name for playerctl (the D-Bus service name).
|
||||||
/// Run `playerctl -l` to see available players.
|
/// Run `playerctl -l` to see available players.
|
||||||
|
/// Omit entirely with `--no-mpris` if you only want to watch MPD.
|
||||||
#[arg(long, default_value = DEFAULT_PLAYER)]
|
#[arg(long, default_value = DEFAULT_PLAYER)]
|
||||||
player: String,
|
player: String,
|
||||||
|
|
||||||
|
/// Disable the MPRIS/playerctl watcher. Use this when you only want
|
||||||
|
/// to scrobble from MPD and do not have playerctl set up.
|
||||||
|
#[arg(long)]
|
||||||
|
no_mpris: bool,
|
||||||
|
|
||||||
|
/// Disable the MPD watcher. MPD scrobbling is on by default;
|
||||||
|
/// pass this flag to run MPRIS-only.
|
||||||
|
#[arg(long)]
|
||||||
|
no_mpd: bool,
|
||||||
|
|
||||||
|
/// MPD server hostname or IP address for TCP connections, or an
|
||||||
|
/// absolute path to a Unix domain socket
|
||||||
|
/// (e.g. `/run/mpd/socket`).
|
||||||
|
#[arg(long, default_value = "localhost")]
|
||||||
|
mpd_host: String,
|
||||||
|
|
||||||
|
/// MPD server TCP port. Ignored when `--mpd-host` is a socket path.
|
||||||
|
#[arg(long, default_value = "6600")]
|
||||||
|
mpd_port: u16,
|
||||||
|
|
||||||
/// Path to the SQLite database file. If not specified, defaults to
|
/// Path to the SQLite database file. If not specified, defaults to
|
||||||
/// ~/.local/share/mpris-scrobbler/scrobbles.db (respects $XDG_DATA_HOME).
|
/// ~/.local/share/mpris-scrobbler/scrobbles.db (respects $XDG_DATA_HOME).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -106,16 +147,40 @@ enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
db_path: Option<String>,
|
db_path: Option<String>,
|
||||||
},
|
},
|
||||||
/// Fetch album art and genre info from MusicBrainz for all scrobbled albums.
|
/// Fetch album art and genre info for all scrobbled albums.
|
||||||
///
|
///
|
||||||
/// This command looks up each unique (artist, album) pair that doesn't yet
|
/// Without extra flags, queries MusicBrainz for each unique (artist, album)
|
||||||
/// have cached metadata, queries MusicBrainz for the release, downloads
|
/// pair that lacks cached metadata and downloads cover art from the Cover
|
||||||
/// cover art from the Cover Art Archive, and stores everything locally.
|
/// Art Archive.
|
||||||
|
///
|
||||||
|
/// With `--mpd-covers`, extracts embedded cover art directly from music
|
||||||
|
/// files via MPD's `readpicture` command — no network access required.
|
||||||
|
/// Run this before the MusicBrainz enrichment so that albums already
|
||||||
|
/// covered locally are not needlessly fetched from the Cover Art Archive.
|
||||||
Enrich {
|
Enrich {
|
||||||
/// Re-fetch metadata for all albums, even those already cached.
|
/// Re-fetch metadata for all albums from MusicBrainz, even those
|
||||||
|
/// already cached. Does not affect MPD cover extraction.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
|
||||||
|
/// Extract embedded cover art from music files via MPD's
|
||||||
|
/// `readpicture` command. Populates `album_cache.cover_url` for
|
||||||
|
/// albums that have scrobbles but no cover yet. Fast and fully
|
||||||
|
/// offline — covers are read from the music files MPD already has
|
||||||
|
/// access to, without any network requests.
|
||||||
|
#[arg(long)]
|
||||||
|
mpd_covers: bool,
|
||||||
|
|
||||||
|
/// MPD server hostname, IP address, or Unix socket path.
|
||||||
|
/// Used with `--mpd-covers`.
|
||||||
|
#[arg(long, default_value = "localhost")]
|
||||||
|
mpd_host: String,
|
||||||
|
|
||||||
|
/// MPD server TCP port. Used with `--mpd-covers` when connecting
|
||||||
|
/// via TCP (ignored for Unix socket paths).
|
||||||
|
#[arg(long, default_value = "6600")]
|
||||||
|
mpd_port: u16,
|
||||||
|
|
||||||
/// Path to the SQLite database file. Same default as `watch`.
|
/// Path to the SQLite database file. Same default as `watch`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
db_path: Option<String>,
|
db_path: Option<String>,
|
||||||
@@ -178,113 +243,152 @@ fn default_db_path() -> String {
|
|||||||
// Watch command implementation
|
// Watch command implementation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Run the `watch` subcommand: spawn playerctl processes, read events, and
|
/// Run the `watch` subcommand: spawn playerctl processes and/or an MPD watcher,
|
||||||
/// scrobble tracks to the database.
|
/// read events, and scrobble tracks to the database.
|
||||||
fn run_watch(player: &str, db_path: &str) {
|
///
|
||||||
// Open (or create) the database and wrap it in Arc<Mutex<>> for sharing
|
/// # Arguments
|
||||||
// with the scrobble callback. In practice, only the main thread accesses
|
/// - `player` — MPRIS player name passed to playerctl. Ignored when `use_mpris` is `false`.
|
||||||
// it, but the Mutex is needed because the callback closure is FnMut and
|
/// - `use_mpris` — whether to start the playerctl-based MPRIS watcher.
|
||||||
// could theoretically be called from different contexts.
|
/// - `mpd_config` — if `Some`, start an MPD watcher thread in parallel.
|
||||||
|
/// - `db_path` — path to the SQLite database file.
|
||||||
|
///
|
||||||
|
/// Both watchers, when active, share the same `Arc<Mutex<Connection>>`
|
||||||
|
/// (scrobble writes are infrequent, so mutex contention is negligible) and
|
||||||
|
/// the same `running` flag (both stop on Ctrl+C).
|
||||||
|
fn run_watch(player: &str, use_mpris: bool, mpd_config: Option<mpd::MpdConfig>, db_path: &str) {
|
||||||
|
// Open (or create) the database and wrap in Arc<Mutex<>> for sharing
|
||||||
|
// between the MPRIS callback and the MPD watcher thread.
|
||||||
let conn = db::open_db(db_path).expect("Failed to open database");
|
let conn = db::open_db(db_path).expect("Failed to open database");
|
||||||
let conn = Arc::new(Mutex::new(conn));
|
let conn = Arc::new(Mutex::new(conn));
|
||||||
|
|
||||||
eprintln!("Database: {}", db_path);
|
eprintln!("Database: {}", db_path);
|
||||||
eprintln!("Watching player: {}", player);
|
if use_mpris {
|
||||||
|
eprintln!("Watching MPRIS player: {}", player);
|
||||||
|
}
|
||||||
|
if mpd_config.is_some() {
|
||||||
|
eprintln!("Watching MPD");
|
||||||
|
}
|
||||||
|
|
||||||
// Channel for sending events from reader threads to the main event loop.
|
// Shared shutdown flag. Both the Ctrl+C handler and the MPD watcher thread
|
||||||
|
// observe this flag; when it goes false, each cleans up and returns.
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
// --- Spawn MPD watcher thread (optional) ---
|
||||||
|
// The MPD thread owns its own ScrobbleTracker and runs its own idle loop.
|
||||||
|
// It writes scrobbles directly to the database via a clone of `conn`.
|
||||||
|
// No shared channel with the MPRIS loop — the two watchers are independent.
|
||||||
|
let mpd_handle = if let Some(mpd_cfg) = mpd_config {
|
||||||
|
let conn_clone = conn.clone();
|
||||||
|
let running_clone = running.clone();
|
||||||
|
Some(thread::spawn(move || {
|
||||||
|
mpd::run_mpd_watch(&mpd_cfg, conn_clone, running_clone);
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- MPRIS watcher (optional) ---
|
||||||
|
// If MPRIS is disabled (--no-mpris), we skip spawning playerctl entirely.
|
||||||
|
// In that case the channel is still created (so the Ctrl+C handler has
|
||||||
|
// a sender), and the main loop exits as soon as the shutdown flag is set.
|
||||||
let (tx, rx) = mpsc::channel::<watcher::Event>();
|
let (tx, rx) = mpsc::channel::<watcher::Event>();
|
||||||
|
|
||||||
// --- Spawn playerctl metadata follower ---
|
// Track how many reader threads have sent Eof so we know when both
|
||||||
// This process outputs one tab-separated line per track change:
|
// playerctl processes have ended. Set to 2 when MPRIS is disabled so the
|
||||||
// artist\talbum\ttitle\tmpris:length
|
// main loop exits immediately on Ctrl+C (no processes to wait for).
|
||||||
let metadata_cmd = Command::new("playerctl")
|
let expected_eofs: usize = if use_mpris { 2 } else { 0 };
|
||||||
.args([
|
|
||||||
"-p",
|
|
||||||
player,
|
|
||||||
"--follow",
|
|
||||||
"metadata",
|
|
||||||
"--format",
|
|
||||||
"{{artist}}\t{{album}}\t{{title}}\t{{mpris:length}}",
|
|
||||||
])
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn();
|
|
||||||
|
|
||||||
let metadata_proc = match metadata_cmd {
|
let mut meta_handle = None;
|
||||||
Ok(proc) => proc,
|
let mut status_handle = None;
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to spawn playerctl metadata: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Spawn playerctl status follower ---
|
if use_mpris {
|
||||||
// This process outputs one line per state change: "Playing", "Paused", or "Stopped".
|
// --- Spawn playerctl metadata follower ---
|
||||||
let status_cmd = Command::new("playerctl")
|
// Outputs one tab-separated line per track change:
|
||||||
.args(["-p", player, "--follow", "status"])
|
// artist\talbum\ttitle\tmpris:length
|
||||||
.stdout(Stdio::piped())
|
let metadata_proc = Command::new("playerctl")
|
||||||
.stderr(Stdio::null())
|
.args([
|
||||||
.spawn();
|
"-p",
|
||||||
|
player,
|
||||||
|
"--follow",
|
||||||
|
"metadata",
|
||||||
|
"--format",
|
||||||
|
"{{artist}}\t{{album}}\t{{title}}\t{{mpris:length}}",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn();
|
||||||
|
|
||||||
let status_proc = match status_cmd {
|
// --- Spawn playerctl status follower ---
|
||||||
Ok(proc) => proc,
|
// Outputs one line per state change: "Playing", "Paused", or "Stopped".
|
||||||
Err(e) => {
|
let status_proc = Command::new("playerctl")
|
||||||
eprintln!("Failed to spawn playerctl status: {}", e);
|
.args(["-p", player, "--follow", "status"])
|
||||||
std::process::exit(1);
|
.stdout(Stdio::piped())
|
||||||
}
|
.stderr(Stdio::null())
|
||||||
};
|
.spawn();
|
||||||
|
|
||||||
// --- Metadata reader thread ---
|
match (metadata_proc, status_proc) {
|
||||||
// Reads lines from the metadata process's stdout, parses them into
|
(Ok(meta_proc), Ok(stat_proc)) => {
|
||||||
// `Event::Metadata` values, and sends them through the channel.
|
// --- Metadata reader thread ---
|
||||||
// Sends `Event::Eof` when the process ends (stdout closes).
|
// Reads lines from the metadata process stdout, parses them into
|
||||||
let tx_meta = tx.clone();
|
// `Event::Metadata` values, and sends them through the channel.
|
||||||
let meta_handle = thread::spawn(move || {
|
// Sends `Event::Eof` when the process exits (stdout closes).
|
||||||
let stdout = metadata_proc
|
let tx_meta = tx.clone();
|
||||||
.stdout
|
meta_handle = Some(thread::spawn(move || {
|
||||||
.expect("No stdout for metadata process");
|
let stdout = meta_proc.stdout.expect("No stdout for metadata process");
|
||||||
let reader = std::io::BufReader::new(stdout);
|
let reader = std::io::BufReader::new(stdout);
|
||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
match line {
|
match line {
|
||||||
Ok(l) => {
|
Ok(l) => {
|
||||||
if let Some(event) = watcher::parse_metadata_line(&l)
|
if let Some(event) = watcher::parse_metadata_line(&l)
|
||||||
&& tx_meta.send(event).is_err()
|
&& tx_meta.send(event).is_err()
|
||||||
{
|
{
|
||||||
break; // Receiver dropped — shutting down.
|
break; // Receiver dropped — shutting down.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
let _ = tx_meta.send(watcher::Event::Eof);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Status reader thread ---
|
||||||
|
// Same pattern as the metadata reader, but parses status lines.
|
||||||
|
let tx_status = tx.clone();
|
||||||
|
status_handle = Some(thread::spawn(move || {
|
||||||
|
let stdout = stat_proc.stdout.expect("No stdout for status process");
|
||||||
|
let reader = std::io::BufReader::new(stdout);
|
||||||
|
for line in reader.lines() {
|
||||||
|
match line {
|
||||||
|
Ok(l) => {
|
||||||
|
if let Some(event) = watcher::parse_status_line(&l)
|
||||||
|
&& tx_status.send(event).is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx_status.send(watcher::Event::Eof);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
(meta_result, _) => {
|
||||||
|
// At least one spawn failed — MPRIS watcher cannot run.
|
||||||
|
// Print the error and send synthetic Eofs so the main loop
|
||||||
|
// below exits cleanly; the MPD watcher (if any) continues.
|
||||||
|
if let Err(e) = meta_result {
|
||||||
|
eprintln!("[warn] Failed to spawn playerctl: {}", e);
|
||||||
}
|
}
|
||||||
Err(_) => break, // Read error — process likely ended.
|
eprintln!("[warn] MPRIS watcher will be inactive.");
|
||||||
|
let _ = tx.send(watcher::Event::Eof);
|
||||||
|
let _ = tx.send(watcher::Event::Eof);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Signal that this process has ended.
|
}
|
||||||
let _ = tx_meta.send(watcher::Event::Eof);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Status reader thread ---
|
|
||||||
// Same pattern as the metadata reader, but parses status lines instead.
|
|
||||||
let tx_status = tx.clone();
|
|
||||||
let status_handle = thread::spawn(move || {
|
|
||||||
let stdout = status_proc.stdout.expect("No stdout for status process");
|
|
||||||
let reader = std::io::BufReader::new(stdout);
|
|
||||||
for line in reader.lines() {
|
|
||||||
match line {
|
|
||||||
Ok(l) => {
|
|
||||||
if let Some(event) = watcher::parse_status_line(&l)
|
|
||||||
&& tx_status.send(event).is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = tx_status.send(watcher::Event::Eof);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Ctrl+C handler ---
|
// --- Ctrl+C handler ---
|
||||||
// Sets the `running` flag to false and sends an Eof event to unblock
|
// Sets the `running` flag to false (which the MPD thread observes) and
|
||||||
// the main loop, allowing a graceful shutdown that evaluates the last track.
|
// sends an Eof event to unblock the MPRIS main loop below.
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
let running_clone = running.clone();
|
let running_clone = running.clone();
|
||||||
let tx_ctrlc = tx.clone();
|
let tx_ctrlc = tx.clone();
|
||||||
ctrlc::set_handler(move || {
|
ctrlc::set_handler(move || {
|
||||||
@@ -294,9 +398,10 @@ fn run_watch(player: &str, db_path: &str) {
|
|||||||
})
|
})
|
||||||
.expect("Failed to set Ctrl+C handler");
|
.expect("Failed to set Ctrl+C handler");
|
||||||
|
|
||||||
// --- Main event loop ---
|
// --- MPRIS main event loop ---
|
||||||
// Receives events from both reader threads and the Ctrl+C handler,
|
// Receives events from both reader threads and the Ctrl+C handler,
|
||||||
// and feeds them into the ScrobbleTracker state machine.
|
// and feeds them into the MPRIS ScrobbleTracker state machine.
|
||||||
|
// When MPRIS is disabled, this loop exits immediately on shutdown.
|
||||||
let mut tracker = watcher::create_db_tracker(conn);
|
let mut tracker = watcher::create_db_tracker(conn);
|
||||||
let mut eof_count = 0;
|
let mut eof_count = 0;
|
||||||
|
|
||||||
@@ -305,9 +410,10 @@ fn run_watch(player: &str, db_path: &str) {
|
|||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
if event == watcher::Event::Eof {
|
if event == watcher::Event::Eof {
|
||||||
eof_count += 1;
|
eof_count += 1;
|
||||||
// Wait for both child processes to end before shutting down.
|
// Wait for both playerctl processes to signal completion
|
||||||
// (The Ctrl+C handler also sends Eof, so we may get up to 3.)
|
// before flushing the last track. The Ctrl+C handler also
|
||||||
if eof_count >= 2 {
|
// sends one Eof, so we may receive more than `expected_eofs`.
|
||||||
|
if eof_count >= expected_eofs {
|
||||||
tracker.handle_event(watcher::Event::Eof);
|
tracker.handle_event(watcher::Event::Eof);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -320,17 +426,25 @@ fn run_watch(player: &str, db_path: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final evaluation: ensure the last track is scrobbled if it qualifies.
|
// Final flush: safe even if Eof was already handled — the tracker
|
||||||
// This is safe to call even if Eof was already handled above — the tracker
|
// is a no-op when there is no current track.
|
||||||
// handles the "no current track" case gracefully.
|
|
||||||
tracker.handle_event(watcher::Event::Eof);
|
tracker.handle_event(watcher::Event::Eof);
|
||||||
|
|
||||||
|
// Wait for the MPD watcher thread to finish its graceful shutdown.
|
||||||
|
// It will notice `running == false` on the next idle timeout (≤ 500 ms).
|
||||||
|
if let Some(handle) = mpd_handle {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
|
||||||
eprintln!("Goodbye.");
|
eprintln!("Goodbye.");
|
||||||
|
|
||||||
// Wait for reader threads to finish (they should already be done since
|
// Wait for MPRIS reader threads to finish.
|
||||||
// the child processes have ended or been killed).
|
if let Some(h) = meta_handle {
|
||||||
let _ = meta_handle.join();
|
let _ = h.join();
|
||||||
let _ = status_handle.join();
|
}
|
||||||
|
if let Some(h) = status_handle {
|
||||||
|
let _ = h.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Round a value to the nearest multiple of 5.
|
/// Round a value to the nearest multiple of 5.
|
||||||
@@ -494,9 +608,27 @@ fn main() {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Watch { player, db_path } => {
|
Commands::Watch {
|
||||||
|
player,
|
||||||
|
no_mpris,
|
||||||
|
no_mpd,
|
||||||
|
mpd_host,
|
||||||
|
mpd_port,
|
||||||
|
db_path,
|
||||||
|
} => {
|
||||||
let path = db_path.unwrap_or_else(default_db_path);
|
let path = db_path.unwrap_or_else(default_db_path);
|
||||||
run_watch(&player, &path);
|
|
||||||
|
// MPD is on by default; --no-mpd disables it.
|
||||||
|
let mpd_config = if no_mpd {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(mpd::MpdConfig {
|
||||||
|
host: mpd_host,
|
||||||
|
port: mpd_port,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
run_watch(&player, !no_mpris, mpd_config, &path);
|
||||||
}
|
}
|
||||||
Commands::Report {
|
Commands::Report {
|
||||||
period,
|
period,
|
||||||
@@ -512,7 +644,13 @@ fn main() {
|
|||||||
all_time_limit.unwrap_or_else(|| round_to_5((limit as f64 * 2.5).round() as i64));
|
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);
|
run_report(&period, json, html, output.as_deref(), limit, atl, &path);
|
||||||
}
|
}
|
||||||
Commands::Enrich { force, db_path } => {
|
Commands::Enrich {
|
||||||
|
force,
|
||||||
|
mpd_covers,
|
||||||
|
mpd_host,
|
||||||
|
mpd_port,
|
||||||
|
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) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -521,6 +659,20 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Run MPD cover extraction first (fast, local, no rate limits).
|
||||||
|
// Pre-populating cover_url means the online enrichment that follows
|
||||||
|
// will skip fetching covers for albums that already have one.
|
||||||
|
if mpd_covers {
|
||||||
|
let mpd_cfg = mpd::MpdConfig {
|
||||||
|
host: mpd_host,
|
||||||
|
port: mpd_port,
|
||||||
|
};
|
||||||
|
mpd::run_mpd_cover_enrich(&mpd_cfg, &conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online enrichment: MusicBrainz lookup for MBID + genre, Cover Art
|
||||||
|
// Archive for any albums still missing a cover after the MPD pass.
|
||||||
enrich::run_enrich(&conn, force, false);
|
enrich::run_enrich(&conn, force, false);
|
||||||
}
|
}
|
||||||
Commands::LastScrobble { db_path } => {
|
Commands::LastScrobble { db_path } => {
|
||||||
|
|||||||
@@ -1476,9 +1476,7 @@ fn pin_album_cmd(artist: &str, album: &str, mbid: Option<&str>) -> String {
|
|||||||
let a = artist.replace('"', "\\\"");
|
let a = artist.replace('"', "\\\"");
|
||||||
let b = album.replace('"', "\\\"");
|
let b = album.replace('"', "\\\"");
|
||||||
let m = mbid.unwrap_or("MBID_HERE");
|
let m = mbid.unwrap_or("MBID_HERE");
|
||||||
format!(
|
format!("mpris-scrobbler pin-album --artist \"{a}\" --album \"{b}\" --mbid \"{m}\"")
|
||||||
"mpris-scrobbler pin-album --artist \"{a}\" --album \"{b}\" --mbid \"{m}\""
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn html_escape(s: &str) -> String {
|
fn html_escape(s: &str) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user