The beginning of something great

This commit is contained in:
2026-03-20 20:00:10 +00:00
commit 24df1854f5
15 changed files with 9729 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2812
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "mpris-scrobbler"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
rusqlite = { version = "0.31", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ctrlc = "3"
reqwest = { version = "0.12", features = ["blocking", "json"] }
libc = "0.2"
image = { version = "0.25", features = ["jpeg"] }

9
FUTURE.md Normal file
View File

@@ -0,0 +1,9 @@
# Future Ideas
## Genre Enrichment Fallback
- Add a Last.fm genre fallback when MusicBrainz returns no usable genre data.
- Keep MusicBrainz as primary source; use Last.fm only as a secondary source.
- Add API key configuration (environment variable + CLI/docs guidance).
- Cache Last.fm-derived genres in `album_cache` and mark source for transparency.
- Keep existing cooldown behaviour to avoid repeated API calls.

451
README.md Normal file
View File

@@ -0,0 +1,451 @@
# mpris-scrobbler
A local music scrobbler for MPRIS-compatible players, built in Rust. It tracks what you listen to via `playerctl`, stores scrobble data in a local SQLite database, and generates listening reports.
## Features
- **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
- **Terminal-style typography** — HTML uses JetBrains Mono with monospace fallbacks
- **Fair ranking tie-breaks** — top artists/albums/tracks are ranked by plays first, then total listen time
- **Album visuals + bars** — HTML shows both large album cover grids and top-album bar tables
- **Genre stats** — reports include top genres (plays + listen time) when metadata is available
- **Mood labels by period** — each report section highlights up to 6 dominant genres
- **Mobile jump menu** — sticky section links (Today/Week/Month/All Time) reduce scrolling on phones
- **Enrichment** — fetch album covers + genres from MusicBrainz/Cover Art Archive
- **Incremental publish helper** — query latest scrobble and publish only when new data exists
- **Configurable player** — defaults to `com.blitzfc.qbz`, configurable via `--player`
## Requirements
- [playerctl](https://github.com/altdesktop/playerctl)
- Rust toolchain (for building)
## 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/mpris-scrobbler ~/.local/bin/mpris-scrobbler
```
Make sure `~/.local/bin` is in your `PATH`:
```bash
command -v mpris-scrobbler
```
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
mpris-scrobbler --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/mpris-scrobbler.service ~/.config/systemd/user/
```
If needed, edit the player in the service (`--player com.blitzfc.qbz`).
### 2) Enable and start
```bash
systemctl --user daemon-reload
systemctl --user enable --now mpris-scrobbler.service
```
### 3) Verify
```bash
systemctl --user status mpris-scrobbler.service
journalctl --user -u mpris-scrobbler.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.
## Report workflow
Recommended workflow:
- Keep only `watch` running as a service.
- Generate HTML reports manually whenever you want:
```bash
mpris-scrobbler report --html --output ~/music-report
```
This creates a self-contained directory:
```
~/music-report/
├── index.html # Open this in a browser
└── covers/
├── <mbid1>.jpg
├── <mbid2>.jpg
└── ...
```
When `--html` is used, the tool automatically runs enrichment first (for missing albums) so covers/genres are available in the generated report.
## Building
```bash
cargo build --release
```
The binary will be at `target/release/mpris-scrobbler`.
## Usage
### Scrobbling
Start the watcher to begin recording what you listen to:
```bash
# Default player (com.blitzfc.qbz)
mpris-scrobbler watch
# Specify a different player
mpris-scrobbler watch --player spotify
```
The watcher runs in the foreground and logs scrobbles to stderr:
```
Database: /home/user/.local/share/mpris-scrobbler/scrobbles.db
Watching player: com.blitzfc.qbz
[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
mpris-scrobbler report
# Filter by period
mpris-scrobbler report --period today
mpris-scrobbler report --period week
mpris-scrobbler report --period month
mpris-scrobbler report --period year
# JSON output
mpris-scrobbler report --json
# HTML output to stdout (no covers)
mpris-scrobbler report --html
# HTML output to directory (with covers)
mpris-scrobbler report --html --output ~/music-report
# Change the number of entries in top-N lists (default: 20)
mpris-scrobbler report --limit 20
```
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
```
mpris-scrobbler watch [OPTIONS]
--player <NAME> Player name for playerctl [default: com.blitzfc.qbz]
--db-path <PATH> Path to the SQLite database
mpris-scrobbler 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 <LIMIT> Number of entries in top-N lists [default: 10]
--all-time-limit <N> Override all-time top-N limit [default: 2.5x --limit]
--db-path <PATH> Path to the SQLite database
mpris-scrobbler enrich [OPTIONS]
--force Re-fetch metadata for all albums
--db-path <PATH> Path to the SQLite database
mpris-scrobbler last-scrobble [OPTIONS]
--db-path <PATH> Path to the SQLite database
```
### Enrichment (covers + genres)
Enrichment is automatic for `report --html`, but you can still run it manually if you want to prefetch metadata:
```bash
mpris-scrobbler enrich
```
When MusicBrainz matching is tricky, enrichment now retries with normalised
album variants (e.g. strips parenthetical suffixes like `(Killing Eve)`, then
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 retry cooldown for
incomplete cache entries (missing cover or missing genre): those entries are
re-tried after 7 days, not on every report run.
Use force mode when you want immediate backfill/refresh for everything:
```bash
mpris-scrobbler enrich --force
```
Genre normalisation notes:
- Genre labels are currently 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 for readability.
- Top Genre and Mood sections aggregate using that normalised grouping.
Downloaded covers are stored in:
`~/.local/share/mpris-scrobbler/covers/`
#### 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
mpris-scrobbler 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
mpris-scrobbler 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
./mpris-scrobbler-publish.sh
```
It runs `report --html` and `rsync` only when a newer scrobble exists.
The script tracks the last published scrobble timestamp in:
`$XDG_STATE_HOME/mpris-scrobbler/last-published-scrobble.txt`
Set defaults in a config file so you can run the script without passing flags:
`~/.config/mpris-scrobbler/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:
`~/.mpris-scrobbler-publish.conf`
Flags:
```bash
./mpris-scrobbler-publish.sh --output ~/music-report --remote user@host:/var/www/music-report
./mpris-scrobbler-publish.sh --db-path /custom/path/scrobbles.db
# Keep running and check every 5 minutes (default interval):
./mpris-scrobbler-publish.sh --watch
# Custom interval (seconds):
./mpris-scrobbler-publish.sh --watch --interval 600
# Force regeneration even when no new scrobbles exist:
./mpris-scrobbler-publish.sh --force
```
The installer also places this helper in `~/.local/bin` as:
`mpris-scrobbler-publish`
## Data storage
Scrobbles are stored in SQLite at `~/.local/share/mpris-scrobbler/scrobbles.db` (respects `$XDG_DATA_HOME`).
Each scrobble records:
| Field | Description |
|-------|-------------|
| artist | Artist name |
| album | Album name |
| title | Track title |
| track_duration_secs | Full track duration in seconds |
| played_duration_secs | Actual time spent listening |
| scrobbled_at | ISO 8601 timestamp |
## How it works
The watcher spawns two `playerctl --follow` processes:
1. **Metadata follower** — emits a line each time the track changes
2. **Status follower** — emits `Playing`, `Paused`, or `Stopped` on state changes
A state machine accumulates play time only while the player is in `Playing` state. When a new track starts (or the player stops), the previous track is evaluated against the scrobble threshold and recorded if it qualifies.
## Troubleshooting
### Service is running but nothing is scrobbled
Check the player name:
```bash
playerctl -l
```
If needed, edit `~/.config/systemd/user/mpris-scrobbler.service` and change `--player ...`, then reload/restart:
```bash
systemctl --user daemon-reload
systemctl --user restart mpris-scrobbler.service
```
### Player is in another account/session
MPRIS is session-scoped. The service must run in the same account/session as the player process.
### Check logs
```bash
journalctl --user -u mpris-scrobbler.service -n 200
journalctl --user -u mpris-scrobbler.service -f
```
### Verify database is being written
```bash
mpris-scrobbler report --period today
```
## Licence
MIT

View File

@@ -0,0 +1,15 @@
# mpris-scrobbler publish helper configuration
#
# Copy to:
# ~/.config/mpris-scrobbler/publish.conf
#
# Or let install.sh install it automatically if missing.
# Local output directory for generated report files.
OUTPUT_DIR="$HOME/music-report"
# Remote rsync target.
REMOTE_TARGET="user@host:/var/www/music-report"
# Optional custom database path (leave empty for default path).
DB_PATH=""

View File

@@ -0,0 +1,13 @@
[Unit]
Description=MPRIS Scrobbler watcher
Documentation=https://github.com/arturmeski/mpris_scrobbler
After=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.local/bin/mpris-scrobbler watch --player com.blitzfc.qbz
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

186
install.sh Executable file
View File

@@ -0,0 +1,186 @@
#!/usr/bin/env bash
#
# Interactive installer for mpris-scrobbler.
#
# Steps:
# 1. Build release binary
# 2. Install to ~/.local/bin/
# 3. Install systemd user service
# 4. Enable and start the service
# 5. Check status and print logs
#
# Each step asks for confirmation before proceeding.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_NAME="mpris-scrobbler"
PUBLISH_SCRIPT_NAME="mpris-scrobbler-publish.sh"
PUBLISH_INSTALLED_NAME="mpris-scrobbler-publish"
PUBLISH_CONFIG_EXAMPLE_SRC="$SCRIPT_DIR/contrib/examples/publish.conf.example"
PUBLISH_CONFIG_DIR="$HOME/.config/mpris-scrobbler"
PUBLISH_CONFIG_FILE="$PUBLISH_CONFIG_DIR/publish.conf"
INSTALL_DIR="$HOME/.local/bin"
SERVICE_SRC="$SCRIPT_DIR/contrib/systemd/user/mpris-scrobbler.service"
SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_NAME="mpris-scrobbler.service"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
# Ask the user for confirmation. Returns 0 if yes, 1 if no.
confirm() {
local prompt="$1"
echo ""
read -rp "$(echo -e "${YELLOW}$prompt [y/N]${NC} ")" answer
case "$answer" in
[yY]|[yY][eE][sS]) return 0 ;;
*) return 1 ;;
esac
}
# -----------------------------------------------------------------------
# Step 1: Build
# -----------------------------------------------------------------------
info "Source directory: $SCRIPT_DIR"
echo ""
if confirm "Step 1/5: Build release binary? (cargo build --release)"; then
info "Building..."
(cd "$SCRIPT_DIR" && cargo build --release)
info "Build complete: $SCRIPT_DIR/target/release/$BIN_NAME"
else
warn "Skipping build. Make sure $SCRIPT_DIR/target/release/$BIN_NAME exists."
fi
# Verify the binary exists before continuing.
if [[ ! -f "$SCRIPT_DIR/target/release/$BIN_NAME" ]]; then
error "Binary not found at $SCRIPT_DIR/target/release/$BIN_NAME"
error "Cannot continue without a built binary. Run 'cargo build --release' first."
exit 1
fi
# -----------------------------------------------------------------------
# Step 2: Install binary and publish helper
# -----------------------------------------------------------------------
if confirm "Step 2/5: Install binary and publish helper to $INSTALL_DIR/?"; then
mkdir -p "$INSTALL_DIR"
install -Dm755 "$SCRIPT_DIR/target/release/$BIN_NAME" "$INSTALL_DIR/$BIN_NAME"
info "Installed: $INSTALL_DIR/$BIN_NAME"
if [[ -f "$SCRIPT_DIR/$PUBLISH_SCRIPT_NAME" ]]; then
install -Dm755 \
"$SCRIPT_DIR/$PUBLISH_SCRIPT_NAME" \
"$INSTALL_DIR/$PUBLISH_INSTALLED_NAME"
info "Installed: $INSTALL_DIR/$PUBLISH_INSTALLED_NAME"
if [[ ! -f "$PUBLISH_CONFIG_FILE" ]]; then
if [[ -f "$PUBLISH_CONFIG_EXAMPLE_SRC" ]]; then
mkdir -p "$PUBLISH_CONFIG_DIR"
install -Dm644 "$PUBLISH_CONFIG_EXAMPLE_SRC" "$PUBLISH_CONFIG_FILE"
info "Installed example config: $PUBLISH_CONFIG_FILE"
else
warn "Publish config example not found: $PUBLISH_CONFIG_EXAMPLE_SRC"
fi
else
info "Keeping existing publish config: $PUBLISH_CONFIG_FILE"
fi
else
warn "Publish helper not found: $SCRIPT_DIR/$PUBLISH_SCRIPT_NAME"
fi
# Check PATH
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
warn "$INSTALL_DIR is not in your PATH."
warn "Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):"
echo ""
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
echo ""
else
info "$INSTALL_DIR is already in PATH."
fi
else
warn "Skipping install."
fi
# -----------------------------------------------------------------------
# Step 3: Install systemd user service
# -----------------------------------------------------------------------
if confirm "Step 3/5: Install systemd user service to $SERVICE_DIR/$SERVICE_NAME?"; then
if [[ ! -f "$SERVICE_SRC" ]]; then
error "Service file not found: $SERVICE_SRC"
exit 1
fi
mkdir -p "$SERVICE_DIR"
cp "$SERVICE_SRC" "$SERVICE_DIR/$SERVICE_NAME"
info "Installed: $SERVICE_DIR/$SERVICE_NAME"
echo ""
info "Current service configuration:"
grep "ExecStart" "$SERVICE_DIR/$SERVICE_NAME"
echo ""
warn "If you need a different --player name, edit:"
warn " $SERVICE_DIR/$SERVICE_NAME"
warn "then run: systemctl --user daemon-reload"
# Reload systemd to pick up the new/updated unit file.
systemctl --user daemon-reload
info "systemd user daemon reloaded."
else
warn "Skipping service installation."
fi
# -----------------------------------------------------------------------
# Step 4: Enable and start
# -----------------------------------------------------------------------
if confirm "Step 4/5: Enable and start $SERVICE_NAME?"; then
systemctl --user enable "$SERVICE_NAME"
info "Service enabled (will start on login)."
systemctl --user restart "$SERVICE_NAME"
info "Service (re)started."
else
warn "Skipping enable/start."
fi
# -----------------------------------------------------------------------
# Step 5: Check status and logs
# -----------------------------------------------------------------------
if confirm "Step 5/5: Check service status and print recent logs?"; then
echo ""
info "Service status:"
echo "---"
systemctl --user status "$SERVICE_NAME" --no-pager || true
echo "---"
echo ""
info "Recent logs:"
echo "---"
journalctl --user -u "$SERVICE_NAME" -n 30 --no-pager || true
echo "---"
else
warn "Skipping status check."
fi
echo ""
info "Done."
info ""
info "Useful commands:"
info " Check status: systemctl --user status $SERVICE_NAME"
info " View logs: journalctl --user -u $SERVICE_NAME -f"
info " Stop service: systemctl --user stop $SERVICE_NAME"
info " Restart: systemctl --user restart $SERVICE_NAME"
info " Generate report: $BIN_NAME report --html --output ~/music-report"
info " Publish report: $PUBLISH_INSTALLED_NAME"
info " Configure defaults in: ~/.config/mpris-scrobbler/publish.conf"

147
mpris-scrobbler-publish.sh Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env bash
set -euo pipefail
# Generate and publish the HTML report only when new scrobbles exist.
#
# Defaults can be overridden by config file and then by flags:
#
# Config file precedence:
# 1) $XDG_CONFIG_HOME/mpris-scrobbler/publish.conf
# 2) ~/.config/mpris-scrobbler/publish.conf
# 3) ~/.mpris-scrobbler-publish.conf (legacy fallback)
#
# Supported config variables:
# OUTPUT_DIR
# REMOTE_TARGET
# DB_PATH
#
# Flags:
# --output <dir>
# --remote <rsync target>
# --db-path <path>
# --watch keep running, checking every --interval seconds
# --interval <secs> seconds between checks in --watch mode (default: 300)
OUTPUT_DIR="${HOME}/music-report"
REMOTE_TARGET="user@host:/var/www/music-report"
DB_PATH=""
WATCH=0
INTERVAL=300
FORCE=0
XDG_CONFIG_BASE="${XDG_CONFIG_HOME:-${HOME}/.config}"
PRIMARY_CONFIG="${XDG_CONFIG_BASE}/mpris-scrobbler/publish.conf"
LEGACY_CONFIG="${HOME}/.mpris-scrobbler-publish.conf"
if [[ -f "${PRIMARY_CONFIG}" ]]; then
# shellcheck disable=SC1090
source "${PRIMARY_CONFIG}"
elif [[ -f "${LEGACY_CONFIG}" ]]; then
# shellcheck disable=SC1090
source "${LEGACY_CONFIG}"
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="$2"
shift 2
;;
--remote)
REMOTE_TARGET="$2"
shift 2
;;
--db-path)
DB_PATH="$2"
shift 2
;;
--watch)
WATCH=1
shift
;;
--interval)
INTERVAL="$2"
shift 2
;;
--force)
FORCE=1
shift
;;
*)
printf 'Unknown option: %s\n' "$1" >&2
exit 2
;;
esac
done
if [[ ! "${INTERVAL}" =~ ^[1-9][0-9]*$ ]]; then
printf 'Invalid --interval value: %s (must be a positive integer)\n' "${INTERVAL}" >&2
exit 2
fi
if ! command -v mpris-scrobbler >/dev/null 2>&1; then
printf 'mpris-scrobbler not found in PATH\n' >&2
exit 1
fi
if ! command -v rsync >/dev/null 2>&1; then
printf 'rsync not found in PATH\n' >&2
exit 1
fi
STATE_HOME="${XDG_STATE_HOME:-${HOME}/.local/state}"
STATE_DIR="${STATE_HOME}/mpris-scrobbler"
MARKER_FILE="${STATE_DIR}/last-published-scrobble.txt"
mkdir -p "${STATE_DIR}"
do_publish() {
local last_args=()
if [[ -n "${DB_PATH}" ]]; then
last_args+=(--db-path "${DB_PATH}")
fi
local latest_scrobble
latest_scrobble="$(mpris-scrobbler last-scrobble "${last_args[@]}")"
if [[ -z "${latest_scrobble}" ]]; then
printf 'No scrobbles yet. Nothing to publish.\n'
return 0
fi
local last_published=""
if [[ -f "${MARKER_FILE}" ]]; then
last_published="$(<"${MARKER_FILE}")"
fi
if [[ "${last_published}" == "${latest_scrobble}" ]]; then
if [[ "${FORCE}" -eq 1 ]]; then
printf 'No new scrobbles since %s, but --force set. Regenerating anyway...\n' "${latest_scrobble}"
else
printf 'No new scrobbles since %s. Skipping publish.\n' "${latest_scrobble}"
return 0
fi
else
printf 'New scrobbles detected (latest: %s). Regenerating report...\n' "${latest_scrobble}"
fi
local report_args=(report --html --output "${OUTPUT_DIR}")
if [[ -n "${DB_PATH}" ]]; then
report_args+=(--db-path "${DB_PATH}")
fi
mpris-scrobbler "${report_args[@]}"
printf 'Publishing via rsync to %s...\n' "${REMOTE_TARGET}"
rsync -Pavz "${OUTPUT_DIR}" "${REMOTE_TARGET}"
printf '%s\n' "${latest_scrobble}" > "${MARKER_FILE}"
printf 'Publish complete. Marker updated at %s\n' "${MARKER_FILE}"
}
if [[ "${WATCH}" -eq 1 ]]; then
printf 'Watch mode: checking every %d seconds. Press Ctrl+C to stop.\n' "${INTERVAL}"
while true; do
do_publish
sleep "${INTERVAL}"
done
else
do_publish
fi

1380
src/db.rs Normal file

File diff suppressed because it is too large Load Diff

1283
src/enrich.rs Normal file

File diff suppressed because it is too large Load Diff

562
src/main.rs Normal file
View File

@@ -0,0 +1,562 @@
//! MPRIS Scrobbler — a local music scrobbler for MPRIS-compatible players.
//!
//! This is the CLI entry point. It provides four subcommands:
//!
//! - `watch` — monitors a player via `playerctl` and records scrobbles to SQLite
//! - `report` — generates listening statistics from the stored scrobble data
//! - `enrich` — fetches album art and genre info from MusicBrainz
//! - `last-scrobble` — prints the newest scrobble timestamp
//! - `pin-album` — manually assign a MusicBrainz ID to an album the automatic search missed
//!
//! ## Architecture overview
//!
//! The `watch` command spawns two `playerctl --follow` child processes:
//!
//! 1. **Metadata follower** — emits a line each time the track changes,
//! providing artist, album, title, and duration.
//! 2. **Status follower** — emits "Playing", "Paused", or "Stopped" on
//! playback state changes.
//!
//! Each child process gets its own reader thread that parses lines and sends
//! typed `Event` values over an `mpsc::channel` to the main thread. The main
//! thread owns the `ScrobbleTracker` state machine, which processes events
//! sequentially and decides when to write scrobbles to the database.
//!
//! ```text
//! [playerctl metadata] ──reader thread──→ ┐
//! ├─ mpsc::channel ─→ [main: ScrobbleTracker → SQLite]
//! [playerctl status] ──reader thread──→ ┘
//! ```
//!
//! Ctrl+C triggers a graceful shutdown: the handler sends an `Eof` event
//! through the channel, causing the tracker to evaluate the last track
//! before exiting.
mod db;
mod enrich;
mod report;
mod watcher;
use clap::{Parser, Subcommand};
use std::io::BufRead;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
/// Default playerctl player name. This matches the MPRIS bus name for the
/// user's primary player (qbz). Can be overridden with `--player`.
const DEFAULT_PLAYER: &str = "com.blitzfc.qbz";
// ---------------------------------------------------------------------------
// CLI definition (using clap derive)
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(name = "mpris-scrobbler", about = "MPRIS scrobbler using playerctl")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Watch playerctl metadata and scrobble tracks to the local database.
Watch {
/// Player name for playerctl (the MPRIS bus name).
/// Run `playerctl -l` to see available players.
#[arg(long, default_value = DEFAULT_PLAYER)]
player: String,
/// Path to the SQLite database file. If not specified, defaults to
/// ~/.local/share/mpris-scrobbler/scrobbles.db (respects $XDG_DATA_HOME).
#[arg(long)]
db_path: Option<String>,
},
/// Generate scrobble reports from the local database.
Report {
/// Time period to report on: today, week, month, year, or all.
#[arg(long, default_value = "all")]
period: String,
/// Output the report as JSON instead of terminal tables.
#[arg(long)]
json: bool,
/// Output the report as standalone HTML.
#[arg(long)]
html: bool,
/// Output directory path (used with --html). Creates a directory with
/// index.html and a covers/ subdirectory. If omitted, prints HTML to stdout.
#[arg(long)]
output: Option<String>,
/// Maximum number of entries in top-N lists (top artists, albums, tracks).
/// All-time sections default to 2.5x this value (rounded to nearest 5).
#[arg(long, default_value = "10")]
limit: i64,
/// Override the all-time top-N limit. If not set, defaults to 2.5x
/// --limit rounded to the nearest multiple of 5.
#[arg(long)]
all_time_limit: Option<i64>,
/// Path to the SQLite database file. Same default as `watch`.
#[arg(long)]
db_path: Option<String>,
},
/// Fetch album art and genre info from MusicBrainz for all scrobbled albums.
///
/// This command looks up each unique (artist, album) pair that doesn't yet
/// have cached metadata, queries MusicBrainz for the release, downloads
/// cover art from the Cover Art Archive, and stores everything locally.
Enrich {
/// Re-fetch metadata for all albums, even those already cached.
#[arg(long)]
force: bool,
/// 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`.
#[arg(long)]
db_path: Option<String>,
},
/// Manually pin a MusicBrainz release ID to an album that automatic search
/// could not find. Fetches genres and cover art for the given MBID and
/// stores them in the album cache, overwriting any previous entry.
PinAlbum {
/// Artist name as it appears in the scrobble database.
#[arg(long)]
artist: String,
/// Album name as it appears in the scrobble database.
#[arg(long)]
album: String,
/// MusicBrainz release UUID to pin to this album.
#[arg(long)]
mbid: String,
/// Optional direct URL to a cover image (JPEG or PNG). Use this when
/// the Cover Art Archive has no image for the given MBID. The image is
/// downloaded, resized, and stored locally just like a CAA cover.
#[arg(long)]
cover_url: Option<String>,
/// Path to the SQLite database file. Same default as `watch`.
#[arg(long)]
db_path: Option<String>,
},
}
// ---------------------------------------------------------------------------
// Database path resolution
// ---------------------------------------------------------------------------
/// Determine the default database path, following the XDG Base Directory spec.
///
/// Path: $XDG_DATA_HOME/mpris-scrobbler/scrobbles.db
/// Falls back to: ~/.local/share/mpris-scrobbler/scrobbles.db
///
/// Creates the parent directory if it doesn't exist.
fn default_db_path() -> String {
let data_dir = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
let home = std::env::var("HOME").expect("HOME not set");
format!("{}/.local/share", home)
});
let dir = format!("{}/mpris-scrobbler", data_dir);
std::fs::create_dir_all(&dir).expect("Failed to create data directory");
format!("{}/scrobbles.db", dir)
}
// ---------------------------------------------------------------------------
// Watch command implementation
// ---------------------------------------------------------------------------
/// Run the `watch` subcommand: spawn playerctl processes, 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
// with the scrobble callback. In practice, only the main thread accesses
// it, but the Mutex is needed because the callback closure is FnMut and
// could theoretically be called from different contexts.
let conn = db::open_db(db_path).expect("Failed to open database");
let conn = Arc::new(Mutex::new(conn));
eprintln!("Database: {}", db_path);
eprintln!("Watching player: {}", player);
// Channel for sending events from reader threads to the main event loop.
let (tx, rx) = mpsc::channel::<watcher::Event>();
// --- Spawn playerctl metadata follower ---
// This process outputs one tab-separated line per track change:
// artist\talbum\ttitle\tmpris:length
let metadata_cmd = Command::new("playerctl")
.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 {
Ok(proc) => proc,
Err(e) => {
eprintln!("Failed to spawn playerctl metadata: {}", e);
std::process::exit(1);
}
};
// --- Spawn playerctl status follower ---
// This process outputs one line per state change: "Playing", "Paused", or "Stopped".
let status_cmd = Command::new("playerctl")
.args(["-p", player, "--follow", "status"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
let status_proc = match status_cmd {
Ok(proc) => proc,
Err(e) => {
eprintln!("Failed to spawn playerctl status: {}", e);
std::process::exit(1);
}
};
// --- Metadata reader thread ---
// Reads lines from the metadata process's stdout, parses them into
// `Event::Metadata` values, and sends them through the channel.
// Sends `Event::Eof` when the process ends (stdout closes).
let tx_meta = tx.clone();
let meta_handle = thread::spawn(move || {
let stdout = metadata_proc
.stdout
.expect("No stdout for metadata process");
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(l) => {
if let Some(event) = watcher::parse_metadata_line(&l)
&& tx_meta.send(event).is_err()
{
break; // Receiver dropped — shutting down.
}
}
Err(_) => break, // Read error — process likely ended.
}
}
// 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 ---
// Sets the `running` flag to false and sends an Eof event to unblock
// the main loop, allowing a graceful shutdown that evaluates the last track.
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let tx_ctrlc = tx.clone();
ctrlc::set_handler(move || {
eprintln!("\nShutting down...");
running_clone.store(false, Ordering::SeqCst);
let _ = tx_ctrlc.send(watcher::Event::Eof);
})
.expect("Failed to set Ctrl+C handler");
// --- Main event loop ---
// Receives events from both reader threads and the Ctrl+C handler,
// and feeds them into the ScrobbleTracker state machine.
let mut tracker = watcher::create_db_tracker(conn);
let mut eof_count = 0;
while running.load(Ordering::SeqCst) {
match rx.recv() {
Ok(event) => {
if event == watcher::Event::Eof {
eof_count += 1;
// Wait for both child processes to end before shutting down.
// (The Ctrl+C handler also sends Eof, so we may get up to 3.)
if eof_count >= 2 {
tracker.handle_event(watcher::Event::Eof);
break;
}
continue;
}
tracker.handle_event(event);
}
// Channel disconnected — all senders dropped.
Err(_) => break,
}
}
// Final evaluation: ensure the last track is scrobbled if it qualifies.
// This is safe to call even if Eof was already handled above — the tracker
// handles the "no current track" case gracefully.
tracker.handle_event(watcher::Event::Eof);
eprintln!("Goodbye.");
// Wait for reader threads to finish (they should already be done since
// the child processes have ended or been killed).
let _ = meta_handle.join();
let _ = status_handle.join();
}
/// Round a value to the nearest multiple of 5.
fn round_to_5(n: i64) -> i64 {
((n + 2) / 5) * 5
}
// ---------------------------------------------------------------------------
// Report command implementation
// ---------------------------------------------------------------------------
/// Run the `report` subcommand.
///
/// 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.
/// With `--output <dir>`, writes `index.html` + `covers/` to a directory.
fn run_report(
period: &str,
json: bool,
html: bool,
output: Option<&str>,
limit: i64,
all_time_limit: i64,
db_path: &str,
) {
let conn = match db::open_db(db_path) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open database at {}: {}", db_path, e);
std::process::exit(1);
}
};
// Validate the period argument before querying.
let valid_periods = ["today", "week", "month", "year", "all"];
if !valid_periods.contains(&period) {
eprintln!(
"Invalid period '{}'. Valid options: {}",
period,
valid_periods.join(", ")
);
std::process::exit(1);
}
if json && html {
eprintln!("Please choose one output format: either --json or --html.");
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 {
let needed = report::albums_needed_for_report(&conn, limit, all_time_limit);
enrich::run_enrich_targeted(&conn, &needed, true);
}
if html {
// HTML report gathers all periods (today, week, month, all) internally.
let html_report = report::render_html_report(&conn, limit, all_time_limit);
if let Some(dir) = output {
// Create the output directory structure:
// <dir>/index.html
// <dir>/covers/<filename>.jpg
let dir_path = std::path::Path::new(dir);
let covers_dir = dir_path.join("covers");
if let Err(e) = std::fs::create_dir_all(&covers_dir) {
eprintln!("Failed to create directory {}: {}", covers_dir.display(), e);
std::process::exit(1);
}
// Copy cover image files into the output covers/ subdirectory.
// Skip files where the destination is already up-to-date (same
// size AND dest is not older than src), so repeated report runs
// are fast but a re-pinned cover is always picked up.
let mut copied = 0;
let mut skipped = 0;
for src in &html_report.cover_files {
if let Some(filename) = src.file_name() {
let dest = covers_dir.join(filename);
let src_meta = std::fs::metadata(src);
let dest_meta = std::fs::metadata(&dest);
let up_to_date = match (src_meta, dest_meta) {
(Ok(s), Ok(d)) => {
let same_size = s.len() == d.len() && s.len() > 0;
let dest_fresh = d
.modified()
.ok()
.zip(s.modified().ok())
.map(|(dt, st)| dt >= st)
.unwrap_or(false);
same_size && dest_fresh
}
_ => false,
};
if up_to_date {
skipped += 1;
continue;
}
if let Err(e) = std::fs::copy(src, &dest) {
eprintln!(" [warn] Failed to copy {}: {}", src.display(), e);
} else {
copied += 1;
}
}
}
// Write the HTML file.
let index_path = dir_path.join("index.html");
if let Err(e) = std::fs::write(&index_path, &html_report.html) {
eprintln!("Failed to write {}: {}", index_path.display(), e);
std::process::exit(1);
}
eprintln!("Wrote HTML report: {}", index_path.display());
if copied > 0 || skipped > 0 {
eprintln!(
"Covers: {} copied, {} unchanged in {}",
copied,
skipped,
covers_dir.display()
);
}
} else {
// No --output: print HTML to stdout (covers won't load,
// but useful for piping).
print!("{}", html_report.html);
}
return;
}
// Terminal or JSON report: single period.
let effective_limit = if period == "all" {
all_time_limit
} else {
limit
};
match report::gather_report(&conn, period, effective_limit) {
Ok(data) => {
if json {
report::print_json_report(&data);
} else {
report::print_terminal_report(&data);
}
}
Err(e) => {
eprintln!("Failed to generate report: {}", e);
std::process::exit(1);
}
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Watch { player, db_path } => {
let path = db_path.unwrap_or_else(default_db_path);
run_watch(&player, &path);
}
Commands::Report {
period,
json,
html,
output,
limit,
all_time_limit,
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);
}
Commands::Enrich { force, 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);
}
};
enrich::run_enrich(&conn, force, false);
}
Commands::LastScrobble { 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);
}
};
match db::latest_scrobble_at(&conn) {
Ok(Some(ts)) => println!("{}", ts),
Ok(None) => {}
Err(e) => {
eprintln!("Failed to query latest scrobble: {}", e);
std::process::exit(1);
}
}
}
Commands::PinAlbum {
artist,
album,
mbid,
cover_url,
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);
}
};
enrich::enrich_by_mbid(&conn, &artist, &album, &mbid, cover_url.as_deref());
}
}
}

1840
src/report.rs Normal file

File diff suppressed because it is too large Load Diff

890
src/watcher.rs Normal file
View File

@@ -0,0 +1,890 @@
//! Watcher module — tracks playback state and decides when to scrobble.
//!
//! This module implements the core scrobbling logic. It processes two streams
//! of events coming from two separate `playerctl --follow` processes:
//!
//! 1. **Metadata events** — emitted when the currently playing track changes.
//! Each event contains the artist, album, title, and duration (in microseconds).
//!
//! 2. **Status events** — emitted when the player's state changes between
//! Playing, Paused, and Stopped.
//!
//! The `ScrobbleTracker` state machine combines these events to accurately
//! measure how long the user actually listened to each track (excluding paused
//! time), and decides whether to scrobble based on the Last.fm threshold:
//! **50% of the track's duration or 4 minutes, whichever is shorter**.
//!
//! ## Architecture
//!
//! The tracker is generic over a callback function (`scrobble_fn`), which is
//! called whenever a track qualifies for scrobbling. In production, this
//! callback inserts into SQLite. In tests, it pushes to a `Vec` for assertion.
//!
//! For testing, a separate `TestableTracker` struct exists that replaces
//! `Instant::now()` with a manually-advanced clock, so we can simulate
//! time passing without actual delays.
use std::time::Instant;
use crate::db::{self, NewScrobble};
use rusqlite::Connection;
// ---------------------------------------------------------------------------
// Event types
// ---------------------------------------------------------------------------
/// Events processed by the watcher's main event loop.
///
/// These are sent over an `mpsc::channel` from the reader threads (one per
/// playerctl process) to the main thread which owns the `ScrobbleTracker`.
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
/// A new track started playing. Emitted when playerctl's metadata output
/// produces a new line (i.e., the track changed).
Metadata {
artist: String,
album: String,
title: String,
/// Track duration in microseconds, as reported by MPRIS (`mpris:length`).
/// For example, 186_000_000 = 186 seconds = 3m06s.
/// `None` if the player didn't provide duration info.
duration_us: Option<u64>,
},
/// The player's playback state changed (Playing, Paused, or Stopped).
Status(PlayerStatus),
/// One of the playerctl child processes ended (stdout closed).
/// When both metadata and status processes have sent Eof, the watcher
/// evaluates the last track and shuts down.
Eof,
}
/// Possible playback states reported by `playerctl --follow status`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PlayerStatus {
Playing,
Paused,
Stopped,
}
// ---------------------------------------------------------------------------
// Internal track representation
// ---------------------------------------------------------------------------
/// Holds metadata about the track currently being monitored.
/// This is internal to the tracker — not exposed outside the module.
#[derive(Debug, Clone)]
struct CurrentTrack {
artist: String,
album: String,
title: String,
/// Duration in microseconds as reported by MPRIS, or None if unavailable.
duration_us: Option<u64>,
}
impl CurrentTrack {
/// Calculate the scrobble threshold for this track.
///
/// Follows the Last.fm convention:
/// - If duration is known: min(50% of duration, 240 seconds)
/// - If duration is unknown: 240 seconds (4 minutes)
///
/// This means short tracks (< 8 min) need 50% play time, while long
/// tracks cap out at 4 minutes of required listening.
fn threshold_secs(&self) -> f64 {
match self.duration_us {
Some(us) => {
let half = (us as f64 / 1_000_000.0) * 0.5;
half.min(240.0)
}
None => 240.0,
}
}
/// Convert the MPRIS duration (microseconds) to whole seconds for storage.
/// Returns None if the duration was not provided by the player.
fn duration_secs(&self) -> Option<i64> {
self.duration_us.map(|us| (us / 1_000_000) as i64)
}
}
// ---------------------------------------------------------------------------
// ScrobbleTracker — production version using real wall-clock time
// ---------------------------------------------------------------------------
/// The core state machine that tracks playback and decides when to scrobble.
///
/// Generic over `F` — the callback invoked when a track qualifies for
/// scrobbling. This allows the same logic to be used with a real DB callback
/// in production and a test collector in unit tests.
///
/// ## State
///
/// - `current_track` — the track currently being monitored (None if idle)
/// - `is_playing` — whether the player is currently in Playing state
/// - `playing_since` — the `Instant` when the current Playing stretch began
/// (None if paused or no track)
/// - `accumulated_secs` — total seconds of actual play time for the current
/// track, accumulated across multiple play/pause cycles
pub struct ScrobbleTracker<F: FnMut(NewScrobble)> {
current_track: Option<CurrentTrack>,
is_playing: bool,
playing_since: Option<Instant>,
accumulated_secs: f64,
scrobble_fn: F,
}
impl<F: FnMut(NewScrobble)> ScrobbleTracker<F> {
pub fn new(scrobble_fn: F) -> Self {
Self {
current_track: None,
is_playing: false,
playing_since: None,
accumulated_secs: 0.0,
scrobble_fn,
}
}
/// Process an incoming event and update internal state.
///
/// ## Event handling:
///
/// - **Metadata**: A new track started. Evaluate the previous track
/// (scrobble if threshold met), then begin tracking the new one.
/// We assume the new track starts in Playing state.
///
/// - **Status(Playing)**: Resume accumulating play time. If already
/// playing, this is a no-op (avoids double-counting).
///
/// - **Status(Paused)**: Flush the current playing stretch into
/// `accumulated_secs` and stop the clock. The track remains active
/// so play time continues to accumulate when resumed.
///
/// - **Status(Stopped)**: Evaluate the current track immediately (the
/// same logic as a new Metadata event or Eof). If the threshold is
/// met, scrobble; if not, discard. Either way, reset tracking state
/// so that a spurious subsequent Status(Playing) cannot accumulate
/// phantom time against the same track.
///
/// - **Eof**: A playerctl process ended. Evaluate the current track
/// one last time (so the final track can be scrobbled on shutdown).
pub fn handle_event(&mut self, event: Event) {
match event {
Event::Metadata {
artist,
album,
title,
duration_us,
} => {
// Evaluate the previous track before switching to the new one.
self.evaluate_previous_track();
// Start tracking the new track.
self.current_track = Some(CurrentTrack {
artist,
album,
title,
duration_us,
});
self.accumulated_secs = 0.0;
// A metadata event means the player is actively playing the new track.
self.is_playing = true;
self.playing_since = Some(Instant::now());
}
Event::Status(status) => match status {
PlayerStatus::Playing => {
// Only start the clock if we weren't already playing.
// This prevents double-counting if we receive redundant Playing events.
if !self.is_playing {
self.is_playing = true;
self.playing_since = Some(Instant::now());
}
}
PlayerStatus::Paused => {
// Flush the elapsed time from the current playing stretch
// into the accumulator, then stop the clock.
self.flush_playing_time();
self.is_playing = false;
self.playing_since = None;
}
PlayerStatus::Stopped => {
// Treat Stop as a final decision: evaluate the current
// track now (scrobble if threshold met, discard if not)
// and clear all tracking state. This prevents a spurious
// Status(Playing) that some players emit after Stopped
// from accumulating phantom time against the same track.
self.evaluate_previous_track();
self.accumulated_secs = 0.0;
self.is_playing = false;
}
},
Event::Eof => {
// Evaluate the last track when the player process ends.
self.evaluate_previous_track();
self.current_track = None;
}
}
}
/// If the player is currently in Playing state, calculate how much time
/// has elapsed since `playing_since` and add it to `accumulated_secs`.
/// Resets `playing_since` to None so the time isn't counted twice.
fn flush_playing_time(&mut self) {
if let Some(since) = self.playing_since.take() {
self.accumulated_secs += since.elapsed().as_secs_f64();
}
}
/// Check whether the current track has been played long enough to qualify
/// for scrobbling. If so, invoke `scrobble_fn` with the track data.
///
/// This is called:
/// - When a new track starts (to evaluate the outgoing track)
/// - On Eof (to evaluate the last track before shutdown)
fn evaluate_previous_track(&mut self) {
// Flush any in-progress playing time first.
self.flush_playing_time();
if let Some(track) = self.current_track.take() {
let threshold = track.threshold_secs();
// Only scrobble if the user listened for at least the threshold duration.
if self.accumulated_secs >= threshold {
let now = chrono::Local::now()
.naive_local()
.format("%Y-%m-%dT%H:%M:%S")
.to_string();
// Extract duration before moving fields out of `track`.
let track_dur = track.duration_secs();
let scrobble = NewScrobble {
artist: track.artist,
album: track.album,
title: track.title,
track_duration_secs: track_dur,
played_duration_secs: self.accumulated_secs.round() as i64,
scrobbled_at: now,
};
(self.scrobble_fn)(scrobble);
}
}
}
}
// ---------------------------------------------------------------------------
// Parsing functions
// ---------------------------------------------------------------------------
/// Parse a metadata line from playerctl's `--follow metadata --format` output.
///
/// Expected format (tab-separated):
/// `{artist}\t{album}\t{title}\t{mpris:length}`
///
/// The duration field (mpris:length) is optional — if missing or unparseable,
/// it will be `None`. At minimum, we need 3 tab-separated fields (artist,
/// album, title). Lines with both artist and title empty are rejected.
///
/// Example input:
/// `"††† (Crosses)\t††† (Crosses)\tThis Is a Trick\t186000000"`
pub fn parse_metadata_line(line: &str) -> Option<Event> {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 {
return None;
}
let artist = parts[0].trim().to_string();
let album = parts[1].trim().to_string();
let title = parts[2].trim().to_string();
// The 4th field is mpris:length in microseconds. It may be missing entirely,
// empty, or contain a non-numeric value — all of which result in None.
let duration_us = parts.get(3).and_then(|s| s.trim().parse::<u64>().ok());
// Reject lines where both artist and title are empty (no useful metadata).
if artist.is_empty() && title.is_empty() {
return None;
}
Some(Event::Metadata {
artist,
album,
title,
duration_us,
})
}
/// Parse a status line from playerctl's `--follow status` output.
///
/// Expected values: "Playing", "Paused", or "Stopped" (with optional
/// trailing whitespace/newlines).
///
/// Returns `None` for unrecognized status strings.
pub fn parse_status_line(line: &str) -> Option<Event> {
match line.trim() {
"Playing" => Some(Event::Status(PlayerStatus::Playing)),
"Paused" => Some(Event::Status(PlayerStatus::Paused)),
"Stopped" => Some(Event::Status(PlayerStatus::Stopped)),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Factory for production use
// ---------------------------------------------------------------------------
/// Create a `ScrobbleTracker` wired up to insert scrobbles into the database.
///
/// The callback acquires the mutex, inserts the scrobble, and logs the result
/// to stderr. The `Arc<Mutex<Connection>>` is shared with the main thread
/// but only accessed from the main event loop (single-threaded), so contention
/// is minimal.
pub fn create_db_tracker(
conn: std::sync::Arc<std::sync::Mutex<Connection>>,
) -> ScrobbleTracker<impl FnMut(NewScrobble)> {
ScrobbleTracker::new(move |scrobble: NewScrobble| {
let conn = conn.lock().unwrap();
match db::insert_scrobble(&conn, &scrobble) {
Ok(_) => {
eprintln!(
"[scrobbled] {} - {} ({}s)",
scrobble.artist, scrobble.title, scrobble.played_duration_secs
);
}
Err(e) => {
eprintln!("[error] Failed to insert scrobble: {}", e);
}
}
})
}
// ===========================================================================
// Test-only code
// ===========================================================================
/// A version of `ScrobbleTracker` that replaces `Instant::now()` with a
/// manually-controlled clock. This lets tests simulate time passing
/// (e.g., "advance 100 seconds") without real delays.
///
/// Instead of calling a callback, scrobbled tracks are collected into the
/// `scrobbled` Vec for inspection in assertions.
#[cfg(test)]
pub struct TestableTracker {
current_track: Option<CurrentTrack>,
is_playing: bool,
/// Simulated timestamp (in seconds) when the current Playing stretch began.
playing_since_secs: Option<f64>,
/// Accumulated play time for the current track (in seconds).
accumulated_secs: f64,
/// All tracks that were scrobbled during the test.
pub scrobbled: Vec<NewScrobble>,
/// The current simulated time, in seconds since the start of the test.
clock_secs: f64,
}
#[cfg(test)]
impl TestableTracker {
pub fn new() -> Self {
Self {
current_track: None,
is_playing: false,
playing_since_secs: None,
accumulated_secs: 0.0,
scrobbled: Vec::new(),
clock_secs: 0.0,
}
}
/// Advance the simulated clock by the given number of seconds.
/// Call this between events to simulate time passing.
pub fn advance_time(&mut self, secs: f64) {
self.clock_secs += secs;
}
/// Flush elapsed playing time from the simulated clock into the accumulator.
/// Mirrors `ScrobbleTracker::flush_playing_time()` but uses `clock_secs`
/// instead of `Instant::elapsed()`.
fn flush_playing_time(&mut self) {
if let Some(since) = self.playing_since_secs.take() {
self.accumulated_secs += self.clock_secs - since;
}
}
/// Evaluate the current track against the scrobble threshold.
/// If it qualifies, push it onto the `scrobbled` Vec.
fn evaluate_previous_track(&mut self) {
self.flush_playing_time();
if let Some(track) = self.current_track.take() {
let threshold = track.threshold_secs();
if self.accumulated_secs >= threshold {
let track_dur = track.duration_secs();
let scrobble = NewScrobble {
artist: track.artist,
album: track.album,
title: track.title,
track_duration_secs: track_dur,
played_duration_secs: self.accumulated_secs.round() as i64,
scrobbled_at: format!("test-time-{}", self.clock_secs),
};
self.scrobbled.push(scrobble);
}
}
}
/// Process an event — mirrors `ScrobbleTracker::handle_event()` exactly,
/// but uses simulated time instead of real wall-clock time.
pub fn handle_event(&mut self, event: Event) {
match event {
Event::Metadata {
artist,
album,
title,
duration_us,
} => {
self.evaluate_previous_track();
self.current_track = Some(CurrentTrack {
artist,
album,
title,
duration_us,
});
self.accumulated_secs = 0.0;
self.is_playing = true;
self.playing_since_secs = Some(self.clock_secs);
}
Event::Status(status) => match status {
PlayerStatus::Playing => {
if !self.is_playing {
self.is_playing = true;
self.playing_since_secs = Some(self.clock_secs);
}
}
PlayerStatus::Paused => {
self.flush_playing_time();
self.is_playing = false;
self.playing_since_secs = None;
}
PlayerStatus::Stopped => {
self.evaluate_previous_track();
self.accumulated_secs = 0.0;
self.is_playing = false;
}
},
Event::Eof => {
self.evaluate_previous_track();
self.current_track = None;
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// =======================================================================
// Parsing tests
// =======================================================================
#[test]
fn test_parse_metadata_line_normal() {
// Standard line with all four fields present.
let line = "††† (Crosses)\t††† (Crosses)\tThis Is a Trick\t186000000";
let event = parse_metadata_line(line).unwrap();
assert_eq!(
event,
Event::Metadata {
artist: "††† (Crosses)".to_string(),
album: "††† (Crosses)".to_string(),
title: "This Is a Trick".to_string(),
duration_us: Some(186_000_000),
}
);
}
#[test]
fn test_parse_metadata_line_missing_duration() {
// Duration field is present but empty — should parse as None.
let line = "Artist\tAlbum\tTitle\t";
let event = parse_metadata_line(line).unwrap();
assert_eq!(
event,
Event::Metadata {
artist: "Artist".to_string(),
album: "Album".to_string(),
title: "Title".to_string(),
duration_us: None,
}
);
}
#[test]
fn test_parse_metadata_line_no_duration_field() {
// Only three fields (no duration column at all).
let line = "Artist\tAlbum\tTitle";
let event = parse_metadata_line(line).unwrap();
assert_eq!(
event,
Event::Metadata {
artist: "Artist".to_string(),
album: "Album".to_string(),
title: "Title".to_string(),
duration_us: None,
}
);
}
#[test]
fn test_parse_metadata_line_empty_artist_and_title() {
// Both artist and title are empty — should be rejected.
let line = "\tAlbum\t\t100";
assert!(parse_metadata_line(line).is_none());
}
#[test]
fn test_parse_metadata_line_too_few_fields() {
// Only two fields — not enough to form a valid metadata event.
let line = "Artist\tAlbum";
assert!(parse_metadata_line(line).is_none());
}
#[test]
fn test_parse_status_line() {
assert_eq!(
parse_status_line("Playing"),
Some(Event::Status(PlayerStatus::Playing))
);
assert_eq!(
parse_status_line("Paused"),
Some(Event::Status(PlayerStatus::Paused))
);
assert_eq!(
parse_status_line("Stopped"),
Some(Event::Status(PlayerStatus::Stopped))
);
// Unrecognized status should return None.
assert_eq!(parse_status_line("Unknown"), None);
// Trailing newline should be handled gracefully.
assert_eq!(
parse_status_line("Playing\n"),
Some(Event::Status(PlayerStatus::Playing))
);
}
// =======================================================================
// Threshold calculation tests
// =======================================================================
#[test]
fn test_threshold_known_duration() {
let track = CurrentTrack {
artist: "A".into(),
album: "B".into(),
title: "C".into(),
duration_us: Some(186_000_000), // 186 seconds
};
// 50% of 186s = 93s. min(93, 240) = 93s.
assert!((track.threshold_secs() - 93.0).abs() < 0.01);
}
#[test]
fn test_threshold_long_track() {
let track = CurrentTrack {
artist: "A".into(),
album: "B".into(),
title: "C".into(),
duration_us: Some(600_000_000), // 600 seconds = 10 minutes
};
// 50% of 600s = 300s. min(300, 240) = 240s (capped at 4 minutes).
assert!((track.threshold_secs() - 240.0).abs() < 0.01);
}
#[test]
fn test_threshold_unknown_duration() {
let track = CurrentTrack {
artist: "A".into(),
album: "B".into(),
title: "C".into(),
duration_us: None,
};
// Unknown duration defaults to 240s (4 minutes).
assert!((track.threshold_secs() - 240.0).abs() < 0.01);
}
// =======================================================================
// Scrobble decision tests (using TestableTracker with simulated time)
// =======================================================================
#[test]
fn test_scrobble_after_threshold() {
// Play a track for longer than its threshold — it should be scrobbled
// when the next track starts.
let mut tracker = TestableTracker::new();
tracker.handle_event(Event::Metadata {
artist: "††† (Crosses)".into(),
album: "††† (Crosses)".into(),
title: "This Is a Trick".into(),
duration_us: Some(186_000_000), // threshold = 93s
});
// Simulate playing for 100 seconds (above the 93s threshold).
tracker.advance_time(100.0);
// When the next track arrives, the previous one gets evaluated.
tracker.handle_event(Event::Metadata {
artist: "Deftones".into(),
album: "White Pony".into(),
title: "Digital Bath".into(),
duration_us: Some(291_000_000),
});
assert_eq!(tracker.scrobbled.len(), 1);
assert_eq!(tracker.scrobbled[0].title, "This Is a Trick");
assert_eq!(tracker.scrobbled[0].played_duration_secs, 100);
}
#[test]
fn test_no_scrobble_below_threshold() {
// Skip a track after only 10 seconds — should NOT be scrobbled.
let mut tracker = TestableTracker::new();
tracker.handle_event(Event::Metadata {
artist: "††† (Crosses)".into(),
album: "††† (Crosses)".into(),
title: "This Is a Trick".into(),
duration_us: Some(186_000_000), // threshold = 93s
});
tracker.advance_time(10.0); // Only 10s — well below 93s threshold.
tracker.handle_event(Event::Metadata {
artist: "Deftones".into(),
album: "White Pony".into(),
title: "Digital Bath".into(),
duration_us: Some(291_000_000),
});
assert_eq!(tracker.scrobbled.len(), 0);
}
#[test]
fn test_pause_resume_accumulates_correctly() {
// Play 50s → pause for 1 hour → resume → play 60s more.
// Total actual play time = 110s, which exceeds the 107.5s threshold.
// The 1-hour pause should NOT count.
let mut tracker = TestableTracker::new();
tracker.handle_event(Event::Metadata {
artist: "††† (Crosses)".into(),
album: "††† (Crosses)".into(),
title: "Telepathy".into(),
duration_us: Some(215_000_000), // threshold = 107.5s
});
// Play for 50 seconds, then pause.
tracker.advance_time(50.0);
tracker.handle_event(Event::Status(PlayerStatus::Paused));
// Paused for 1 hour — this time should NOT be counted.
tracker.advance_time(3600.0);
tracker.handle_event(Event::Status(PlayerStatus::Playing));
// Play for 60 more seconds. Total play time: 50 + 60 = 110s.
tracker.advance_time(60.0);
// Next track triggers evaluation of "Telepathy".
tracker.handle_event(Event::Metadata {
artist: "Deftones".into(),
album: "White Pony".into(),
title: "Digital Bath".into(),
duration_us: Some(291_000_000),
});
assert_eq!(tracker.scrobbled.len(), 1);
assert_eq!(tracker.scrobbled[0].title, "Telepathy");
// 50 + 60 = 110 seconds of actual play time.
assert_eq!(tracker.scrobbled[0].played_duration_secs, 110);
}
#[test]
fn test_pause_resume_below_threshold() {
// Play 30s → pause → play 30s = 60s total, below the 107.5s threshold.
let mut tracker = TestableTracker::new();
tracker.handle_event(Event::Metadata {
artist: "††† (Crosses)".into(),
album: "††† (Crosses)".into(),
title: "Telepathy".into(),
duration_us: Some(215_000_000), // threshold = 107.5s
});
tracker.advance_time(30.0);
tracker.handle_event(Event::Status(PlayerStatus::Paused));
tracker.advance_time(500.0); // Long pause — doesn't count.
tracker.handle_event(Event::Status(PlayerStatus::Playing));
tracker.advance_time(30.0);
// Total play time = 60s, below 107.5s threshold.
tracker.handle_event(Event::Metadata {
artist: "Deftones".into(),
album: "White Pony".into(),
title: "Digital Bath".into(),
duration_us: Some(291_000_000),
});
assert_eq!(tracker.scrobbled.len(), 0);
}
#[test]
fn test_eof_evaluates_last_track() {
// The last track should be scrobbled when the player process ends (Eof),
// not just when a new track starts.
let mut tracker = TestableTracker::new();
tracker.handle_event(Event::Metadata {
artist: "Deftones".into(),
album: "White Pony".into(),
title: "Digital Bath".into(),
duration_us: Some(291_000_000), // threshold = 145.5s
});
tracker.advance_time(200.0);
tracker.handle_event(Event::Eof);
assert_eq!(tracker.scrobbled.len(), 1);
assert_eq!(tracker.scrobbled[0].title, "Digital Bath");
}
#[test]
fn test_unknown_duration_uses_4min_threshold() {
// When duration is unknown, the threshold falls back to 240s (4 minutes).
let mut tracker = TestableTracker::new();
// First attempt: play for 200s (below 240s) — should NOT scrobble.
tracker.handle_event(Event::Metadata {
artist: "Unknown".into(),
album: "".into(),
title: "Mystery".into(),
duration_us: None,
});
tracker.advance_time(200.0);
tracker.handle_event(Event::Eof);
assert_eq!(tracker.scrobbled.len(), 0);
// Second attempt: play for 250s (above 240s) — should scrobble.
let mut tracker2 = TestableTracker::new();
tracker2.handle_event(Event::Metadata {
artist: "Unknown".into(),
album: "".into(),
title: "Mystery".into(),
duration_us: None,
});
tracker2.advance_time(250.0);
tracker2.handle_event(Event::Eof);
assert_eq!(tracker2.scrobbled.len(), 1);
}
#[test]
fn test_multiple_tracks_sequence() {
// Simulate a listening session with 3 tracks:
// Track 1: played fully (186s > 93s threshold) → scrobbled
// Track 2: skipped quickly (5s < 107.5s threshold) → NOT scrobbled
// Track 3: played fully (291s > 145.5s threshold) → scrobbled
let mut tracker = TestableTracker::new();
// Track 1: ††† (Crosses) - This Is a Trick
tracker.handle_event(Event::Metadata {
artist: "††† (Crosses)".into(),
album: "††† (Crosses)".into(),
title: "This Is a Trick".into(),
duration_us: Some(186_000_000), // threshold = 93s
});
tracker.advance_time(186.0);
// Track 2: ††† (Crosses) - Telepathy (skipped after 5 seconds)
tracker.handle_event(Event::Metadata {
artist: "††† (Crosses)".into(),
album: "††† (Crosses)".into(),
title: "Telepathy".into(),
duration_us: Some(215_000_000), // threshold = 107.5s
});
tracker.advance_time(5.0);
// Track 3: Deftones - Digital Bath
tracker.handle_event(Event::Metadata {
artist: "Deftones".into(),
album: "White Pony".into(),
title: "Digital Bath".into(),
duration_us: Some(291_000_000), // threshold = 145.5s
});
tracker.advance_time(291.0);
// End of session.
tracker.handle_event(Event::Eof);
// Only tracks 1 and 3 should be scrobbled.
assert_eq!(tracker.scrobbled.len(), 2);
assert_eq!(tracker.scrobbled[0].title, "This Is a Trick");
assert_eq!(tracker.scrobbled[1].title, "Digital Bath");
}
#[test]
fn test_stop_below_threshold_no_scrobble() {
// Play a track for 3 seconds then stop — should NOT be scrobbled,
// even if a spurious Status(Playing) follows (some players emit this)
// and a large amount of phantom time elapses before the next event.
let mut tracker = TestableTracker::new();
tracker.handle_event(Event::Metadata {
artist: "††† (Crosses)".into(),
album: "††† (Crosses)".into(),
title: "This Is a Trick".into(),
duration_us: Some(186_000_000), // threshold = 93s
});
tracker.advance_time(3.0);
tracker.handle_event(Event::Status(PlayerStatus::Stopped));
// Spurious Playing emitted by the player after stopping — this used
// to start the clock again, letting phantom time push accumulated_secs
// past the threshold.
tracker.handle_event(Event::Status(PlayerStatus::Playing));
tracker.advance_time(200.0); // phantom time — player isn't actually playing
// Session ends (or a new track arrives).
tracker.handle_event(Event::Eof);
assert_eq!(
tracker.scrobbled.len(),
0,
"3-second play must not be scrobbled"
);
}
#[test]
fn test_stop_above_threshold_scrobbles_immediately() {
// Play a track past its threshold, then stop — should be scrobbled
// at the moment Stop arrives, not waiting for the next track or Eof.
let mut tracker = TestableTracker::new();
tracker.handle_event(Event::Metadata {
artist: "Deftones".into(),
album: "White Pony".into(),
title: "Digital Bath".into(),
duration_us: Some(291_000_000), // threshold = 145.5s
});
tracker.advance_time(200.0); // above threshold
tracker.handle_event(Event::Status(PlayerStatus::Stopped));
assert_eq!(tracker.scrobbled.len(), 1);
assert_eq!(tracker.scrobbled[0].title, "Digital Bath");
assert_eq!(tracker.scrobbled[0].played_duration_secs, 200);
}
}

125
uninstall.sh Executable file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
#
# Interactive uninstaller for mpris-scrobbler.
#
# Steps:
# 1. Stop and disable the systemd user service
# 2. Remove the systemd service unit file
# 3. Remove the installed binary
# 4. Optionally remove all data (database, covers, config)
#
# Each step asks for confirmation before proceeding.
set -euo pipefail
BIN_NAME="mpris-scrobbler"
PUBLISH_BIN_NAME="mpris-scrobbler-publish"
INSTALL_DIR="$HOME/.local/bin"
SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_NAME="mpris-scrobbler.service"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/mpris-scrobbler"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
confirm() {
local prompt="$1"
echo ""
read -rp "$(echo -e "${YELLOW}$prompt [y/N]${NC} ")" answer
case "$answer" in
[yY]|[yY][eE][sS]) return 0 ;;
*) return 1 ;;
esac
}
# -----------------------------------------------------------------------
# Step 1: Stop and disable the service
# -----------------------------------------------------------------------
if systemctl --user is-enabled "$SERVICE_NAME" &>/dev/null || \
systemctl --user is-active "$SERVICE_NAME" &>/dev/null; then
if confirm "Step 1/4: Stop and disable $SERVICE_NAME?"; then
systemctl --user stop "$SERVICE_NAME" 2>/dev/null || true
systemctl --user disable "$SERVICE_NAME" 2>/dev/null || true
info "Service stopped and disabled."
else
warn "Skipping service stop/disable."
fi
else
info "Step 1/4: Service is not installed or not running. Nothing to stop."
fi
# -----------------------------------------------------------------------
# Step 2: Remove service unit file
# -----------------------------------------------------------------------
if [[ -f "$SERVICE_DIR/$SERVICE_NAME" ]]; then
if confirm "Step 2/4: Remove $SERVICE_DIR/$SERVICE_NAME?"; then
rm "$SERVICE_DIR/$SERVICE_NAME"
systemctl --user daemon-reload
info "Service unit removed and daemon reloaded."
else
warn "Skipping service unit removal."
fi
else
info "Step 2/4: No service unit file found. Nothing to remove."
fi
# -----------------------------------------------------------------------
# Step 3: Remove binary and publish helper
# -----------------------------------------------------------------------
if [[ -f "$INSTALL_DIR/$BIN_NAME" || -f "$INSTALL_DIR/$PUBLISH_BIN_NAME" ]]; then
if confirm "Step 3/4: Remove installed binaries from $INSTALL_DIR?"; then
if [[ -f "$INSTALL_DIR/$BIN_NAME" ]]; then
rm "$INSTALL_DIR/$BIN_NAME"
info "Removed: $INSTALL_DIR/$BIN_NAME"
fi
if [[ -f "$INSTALL_DIR/$PUBLISH_BIN_NAME" ]]; then
rm "$INSTALL_DIR/$PUBLISH_BIN_NAME"
info "Removed: $INSTALL_DIR/$PUBLISH_BIN_NAME"
fi
else
warn "Skipping binary removal."
fi
else
info "Step 3/4: No installed binaries found in $INSTALL_DIR. Nothing to remove."
fi
# -----------------------------------------------------------------------
# Step 4: Remove data (database, covers)
# -----------------------------------------------------------------------
if [[ -d "$DATA_DIR" ]]; then
echo ""
warn "Data directory: $DATA_DIR"
if [[ -f "$DATA_DIR/scrobbles.db" ]]; then
local_size=$(du -sh "$DATA_DIR" 2>/dev/null | cut -f1)
scrobble_count=$(sqlite3 "$DATA_DIR/scrobbles.db" "SELECT COUNT(*) FROM scrobbles;" 2>/dev/null || echo "unknown")
info " Database: $DATA_DIR/scrobbles.db ($scrobble_count scrobbles)"
fi
if [[ -d "$DATA_DIR/covers" ]]; then
cover_count=$(find "$DATA_DIR/covers" -type f 2>/dev/null | wc -l)
info " Covers: $DATA_DIR/covers/ ($cover_count files)"
fi
info " Total size: $(du -sh "$DATA_DIR" 2>/dev/null | cut -f1)"
if confirm "Step 4/4: DELETE all scrobble data, covers, and database? (THIS CANNOT BE UNDONE)"; then
rm -rf "$DATA_DIR"
info "Data directory removed."
else
warn "Keeping data at $DATA_DIR."
fi
else
info "Step 4/4: No data directory found at $DATA_DIR. Nothing to remove."
fi
echo ""
info "Uninstall complete."