Files
scrbblr/README.md

554 lines
19 KiB
Markdown

# scrbblr
A local music scrobbler for MPRIS-compatible players and MPD, built in Rust. It tracks what you listen to, stores scrobble data in a local SQLite database, and generates listening reports.
## Features
- **Dual source scrobbling** -- tracks both MPRIS players (via `playerctl`) and MPD simultaneously; each maintains its own tracker writing to the same database
- **Accurate play time tracking** -- monitors both metadata changes and play/pause status, so paused time doesn't count toward scrobble thresholds
- **Last.fm-style scrobble rules** -- a track is scrobbled after 50% of its duration or 4 minutes of play, whichever is shorter
- **Local storage** -- all data stays on your machine in a single SQLite file
- **Reports** -- terminal tables or JSON output, filterable by time period (today, week, month, year, all time)
- **Adaptive terminal tables** -- report columns shrink to fit narrower terminal widths
- **HTML reports** -- generate a standalone dark-themed HTML file with album art cards, bar charts, and genre pills
- **Source colour-coding** -- album cards and bar table rows are tinted by dominant scrobble source (MPRIS vs MPD), with a consistent per-source colour palette
- **Fair ranking tie-breaks** -- top artists/albums/tracks are ranked by plays first, then total listen time
- **Genre stats** -- reports include top genres and per-period "Mood" labels; single-word broad genres (e.g. "ambient", "electronic") are deprioritised below multi-word ones
- **Mobile-optimised report** -- responsive layout with a sticky jump nav; bar graphs drop on small screens leaving just rank + duration
- **Terminal-style typography** -- HTML uses JetBrains Mono with monospace fallbacks
- **Enrichment** -- extract embedded covers from MPD music files (offline), and/or fetch album art + genres from MusicBrainz / iTunes / Cover Art Archive
- **Targeted enrichment** -- `report --html` fetches covers only for the albums that actually appear in the report, not the entire library
- **Incremental publish helper** -- query latest scrobble and publish only when new data exists
- **Configurable player** -- MPRIS player defaults to `com.blitzfc.qbz`, configurable via `--player`
## Requirements
- Rust toolchain (for building)
- [playerctl](https://github.com/altdesktop/playerctl) -- required only for MPRIS scrobbling; omit with `--no-mpris` if you use MPD only
- MPD -- required only for MPD scrobbling; omit with `--no-mpd` if you use MPRIS only
## Installation
### Quick install (interactive)
The repo includes an interactive install script that builds the binary, installs it,
sets up the systemd service, and starts it -- asking before each step:
```bash
./install.sh
```
### Manual install
Build and install the binary into `~/.local/bin`:
```bash
cargo build --release
install -Dm755 target/release/scrbblr ~/.local/bin/scrbblr
```
Make sure `~/.local/bin` is in your `PATH`:
```bash
command -v scrbblr
```
If that prints nothing, add this to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.):
```bash
export PATH="$HOME/.local/bin:$PATH"
```
Then open a new shell and verify:
```bash
scrbblr --help
```
## Autostart (recommended)
Use a **systemd user service** so scrobbling starts automatically when you log in.
### 1) Install the service unit
Copy the provided unit file from the repo:
```bash
mkdir -p ~/.config/systemd/user
cp contrib/systemd/user/scrbblr.service ~/.config/systemd/user/
```
If needed, edit the player in the service (`--player com.blitzfc.qbz`) or add `--no-mpris` / `--no-mpd`.
### 2) Enable and start
```bash
systemctl --user daemon-reload
systemctl --user enable --now scrbblr.service
```
### 3) Verify
```bash
systemctl --user status scrbblr.service
journalctl --user -u scrbblr.service -f
```
### Optional: keep running without active login
If you want user services to keep running after logout/reboot (without an active shell login), enable lingering:
```bash
loginctl enable-linger "$USER"
```
### Important session note
`playerctl` and MPRIS are tied to the session D-Bus. The scrobbler must run in the **same user/session** as your player. If the player runs under another account/session, this service will not see it. MPD does not have this restriction -- it connects over TCP or Unix socket.
## Report workflow
Recommended workflow:
- Keep only `watch` running as a service.
- Generate HTML reports manually whenever you want:
```bash
scrbblr report --html --output ~/music-report
```
This creates a self-contained directory:
```
~/music-report/
├── index.html # Open this in a browser
└── covers/
├── <uuid>.jpg
└── ...
```
When `--html` is used, enrichment runs automatically before rendering -- first extracting embedded covers from MPD for any albums that appear in the report, then fetching missing covers/genres online. Use `--no-enrich` to skip this step.
## Building
```bash
cargo build --release
```
The binary will be at `target/release/scrbblr`.
## Usage
### Scrobbling
Start the watcher to begin recording what you listen to. By default, both the MPRIS watcher (via `playerctl`) and the MPD watcher run simultaneously:
```bash
# Both MPRIS and MPD (default)
scrbblr watch
# MPRIS only (disable MPD watcher)
scrbblr watch --no-mpd
# MPD only (disable MPRIS watcher)
scrbblr watch --no-mpris
# Specify a different MPRIS player
scrbblr watch --player spotify
# Connect to MPD on a non-default host or port
scrbblr watch --mpd-host 192.168.1.10 --mpd-port 6600
# MPD via Unix socket
scrbblr watch --mpd-host /run/mpd/socket
```
The watcher runs in the foreground and logs scrobbles to stderr:
```
Database: /home/user/.local/share/scrbblr/scrobbles.db
Watching MPRIS player: com.blitzfc.qbz
Watching MPD
[scrobbled] ††† (Crosses) - This Is a Trick (186s)
[scrobbled] ††† (Crosses) - Telepathy (200s)
```
Press `Ctrl+C` to stop. The last track will be evaluated before shutdown.
### Reports
All-time sections default to 2.5x the `--limit` value (rounded to nearest 5)
for a broader view; shorter periods use the limit as-is. Default limit is 10,
giving 25 for all-time. Use `--all-time-limit` to override explicitly.
Top lists use this ordering logic:
- Primary: number of plays (descending)
- Secondary: total listen time (descending)
- Final stable tie-break: name fields (artist/album/title)
In HTML reports, album covers are shown in fixed full rows (6 columns on desktop, 3 on tablet, 2 on mobile). For cleaner layout, the cover grid rounds the visual cover count up to the next full desktop row when enough albums exist.
The HTML head loads JetBrains Mono from Google Fonts and falls back to local monospace fonts when offline.
```bash
# All-time summary with top artists, albums, genres, tracks
scrbblr report
# Filter by period
scrbblr report --period today
scrbblr report --period week
scrbblr report --period month
scrbblr report --period year
# JSON output
scrbblr report --json
# HTML output to stdout (no covers)
scrbblr report --html
# HTML output to directory (with covers)
scrbblr report --html --output ~/music-report
# Change the number of entries in top-N lists (default: 10)
scrbblr report --limit 20
# Skip automatic enrichment (faster, but covers/genres may be missing)
scrbblr report --html --output ~/music-report --no-enrich
# Use a non-default MPD host for cover extraction
scrbblr report --html --output ~/music-report --mpd-host /run/mpd/socket
```
Example terminal output:
```
=== Scrobble Report: This Week (2026-03-13 → 2026-03-19) ===
+-------------------+----------+
| Metric | Value |
+-------------------+----------+
| Total scrobbles | 142 |
| Total listen time | 8h 23m |
| Unique artists | 31 |
| Unique albums | 47 |
| Unique tracks | 98 |
+-------------------+----------+
Top Artists
+---+----------------+-------+-------------+
| # | Artist | Plays | Listen Time |
+---+----------------+-------+-------------+
| 1 | ††† (Crosses) | 23 | 1h 12m |
| 2 | Deftones | 18 | 1h 05m |
+---+----------------+-------+-------------+
```
### Options
```
scrbblr watch [OPTIONS]
--player <NAME> MPRIS player name for playerctl [default: com.blitzfc.qbz]
--no-mpris Disable the MPRIS/playerctl watcher
--no-mpd Disable the MPD watcher
--mpd-host <HOST> MPD hostname, IP, or Unix socket path [default: localhost]
--mpd-port <PORT> MPD TCP port [default: 6600]
--db-path <PATH> Path to the SQLite database
scrbblr report [OPTIONS]
--period <PERIOD> today, week, month, year, all [default: all]
--json Output as JSON
--html Output as standalone HTML
--output <PATH> Write HTML report to this directory (index.html + covers/)
--limit <N> Number of entries in top-N lists [default: 10]
--all-time-limit <N> Override all-time top-N limit [default: 2.5x --limit]
--no-enrich Skip automatic cover/genre enrichment before rendering
--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 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
--retry-mpd-genres Reset the 7-day cooldown for MPD albums missing genres (requires --online)
--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
scrbblr pin-album [OPTIONS]
--artist <ARTIST> Artist name as stored in the database
--album <ALBUM> Album name as stored in the database
--mbid <UUID> MusicBrainz release UUID
--cover-url <URL> Direct URL to a cover image (when CAA has none)
--db-path <PATH> Path to the SQLite database
```
### Enrichment (covers + genres)
Enrichment runs in two stages:
**Stage 1 -- MPD embedded covers (offline, no network)**
By default, `enrich` connects to MPD and extracts embedded cover art from your music files using MPD's `readpicture` command. This is fast, works entirely offline, and only processes albums scrobbled via MPD that don't yet have a cover:
```bash
scrbblr enrich
```
**Stage 2 -- Online lookup (MusicBrainz + iTunes + Cover Art Archive)**
Pass `--online` to also query MusicBrainz for album metadata (MBID, genres) and download covers for albums that still have none after the MPD pass:
```bash
scrbblr enrich --online
```
Cover art is sourced from **iTunes first** (fast, no rate limiting), then the **Cover Art Archive** as fallback. Pass `--no-itunes` to skip iTunes and use only CAA:
```bash
scrbblr enrich --online --no-itunes
```
When MusicBrainz matching is tricky, enrichment retries with normalised album variants (strips parenthetical suffixes, progressively shortens trailing words), tries artist aliases for symbol-heavy names, and falls back to recording search before giving up.
Genre extraction order:
1. Release `genres`
2. Release `tags`
3. Release-group `genres`
4. Release-group `tags`
Automatic enrichment (triggered by `report --html`) uses a 7-day retry cooldown for incomplete cache entries (missing cover or genre) so it doesn't hammer MusicBrainz on every report run. Use `--retry-covers` to reset that cooldown and retry albums missing covers immediately:
```bash
scrbblr enrich --online --retry-covers
```
If you specifically want to retry **missing genres for MPD-sourced albums only**:
```bash
scrbblr enrich --online --retry-mpd-genres
```
You can combine this with `--artist` to target one artist.
Use `--force` to re-fetch all albums from scratch (ignores the cooldown entirely):
```bash
scrbblr enrich --online --force
```
Genre normalisation notes:
- Genre labels are passed through from MusicBrainz with light cleanup only.
- We split comma-separated values and trim spaces.
- For aggregation, hyphen/space variants are grouped (e.g. `post-rock` + `post rock`).
- When both forms exist, the spaced form is preferred for display.
- Album cards display at most 3 genre pills, preferring multi-word genres over single-word ones.
- Top Genre and Mood sections aggregate using the same normalised grouping and the same deprioritisation rule.
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
the scrobbled artist tag (e.g. a choir or soloist) doesn't match the release
credits on MusicBrainz, or where the album title wording differs significantly.
Enrich will print:
```
No match found on MusicBrainz.
```
When that happens you can pin the correct MusicBrainz release manually:
1. Search for the release on [musicbrainz.org](https://musicbrainz.org).
2. Open the release page. The MBID is the UUID in the URL:
`https://musicbrainz.org/release/`**`f2ff907a-0355-451b-9c68-f0b7c09bb145`**
3. Copy the exact artist and album strings from the `enrich` output (the
`[N/M] Artist - Album` line) and run:
```bash
scrbblr pin-album \
--artist "Coro della Radiotelevisione Svizzera" \
--album "Vivaldi: Gloria; Nisi Dominus; Nulla in mundo pax" \
--mbid "f2ff907a-0355-451b-9c68-f0b7c09bb145"
```
This fetches genres and cover art for that specific release and stores them in
the local cache, overwriting any previous (failed) entry. Re-run `enrich` or
`report --html` afterwards to pick up the result.
If the Cover Art Archive has no image for the release (the command prints
"No cover art available"), supply one with `--cover-url`:
```bash
scrbblr pin-album \
--artist "Coro della Radiotelevisione Svizzera" \
--album "Vivaldi: Gloria; Nisi Dominus; Nulla in mundo pax" \
--mbid "f2ff907a-0355-451b-9c68-f0b7c09bb145" \
--cover-url "https://example.com/cover.jpg"
```
The image is downloaded, resized to 500 px, and stored locally just like a
Cover Art Archive image. Any HTTPS image URL works -- Discogs, Wikipedia,
Bandcamp, etc.
### Incremental publish script
If you publish the report to a remote host, use the included helper script:
```bash
./scrbblr-publish.sh --help
```
It runs `report --html` and `rsync` only when a newer scrobble exists.
The script tracks the last published scrobble timestamp in:
`$XDG_STATE_HOME/scrbblr/last-published-scrobble.txt`
Set defaults in a config file so you can run the script without passing flags:
`~/.config/scrbblr/publish.conf`
If you use `./install.sh`, an example config is installed there automatically
when the file does not already exist.
Example:
```bash
OUTPUT_DIR="$HOME/music-report"
REMOTE_TARGET="user@host:/var/www/music-report"
DB_PATH=""
```
Legacy fallback is also supported:
`~/.scrbblr-publish.conf`
Flags:
```bash
./scrbblr-publish.sh --output ~/music-report --remote user@host:/var/www/music-report
./scrbblr-publish.sh --db-path /custom/path/scrobbles.db
# Keep running and check every 5 minutes (default interval):
./scrbblr-publish.sh --watch
# Custom interval (seconds):
./scrbblr-publish.sh --watch --interval 600
# Force regeneration even when no new scrobbles exist:
./scrbblr-publish.sh --force
```
The installer also places this helper in `~/.local/bin` as:
`scrbblr-publish`
## Data storage
Scrobbles are stored in SQLite at `~/.local/share/scrbblr/scrobbles.db` (respects `$XDG_DATA_HOME`).
Each scrobble records:
| Field | Description |
|-------|-------------|
| artist | Artist name |
| album | Album name |
| title | Track title |
| source | Scrobble source (`MPRIS` or `MPD`) |
| track_duration_secs | Full track duration in seconds |
| played_duration_secs | Actual time spent listening |
| scrobbled_at | ISO 8601 timestamp |
## How it works
### MPRIS watcher
The `watch` command spawns two `playerctl --follow` child processes:
1. **Metadata follower** -- emits a line each time the track changes
2. **Status follower** -- emits `Playing`, `Paused`, or `Stopped` on state changes
Each process gets its own reader thread that sends typed events over an `mpsc` channel to the main thread. The main thread owns a `ScrobbleTracker` state machine, which processes events sequentially and decides when to write scrobbles to the database.
### MPD watcher
Unless `--no-mpd` is passed, a separate thread connects to MPD using the idle protocol and runs its own `ScrobbleTracker`. It writes to the same database independently -- no synchronisation is needed between the two watchers. The MPD watcher connects over TCP (default: `localhost:6600`) or a Unix socket if a socket path is given as `--mpd-host`.
Both watchers observe a shared shutdown flag. Ctrl+C sets it to false; the MPRIS main loop also receives an `Eof` event through its channel. The MPD watcher notices the flag on its next idle timeout (≤ 500 ms).
## Troubleshooting
### Service is running but nothing is scrobbled
Check the player name:
```bash
playerctl -l
```
If needed, edit `~/.config/systemd/user/scrbblr.service` and change `--player ...`, then reload/restart:
```bash
systemctl --user daemon-reload
systemctl --user restart scrbblr.service
```
### Player is in another account/session
MPRIS is session-scoped. The service must run in the same account/session as the player process. MPD does not have this restriction.
### Check logs
```bash
journalctl --user -u scrbblr.service -n 200
journalctl --user -u scrbblr.service -f
```
### Verify database is being written
```bash
scrbblr report --period today
```
## Licence
MIT