The beginning of something great
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
2812
Cargo.lock
generated
Normal file
2812
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
9
FUTURE.md
Normal 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
451
README.md
Normal 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
|
||||||
15
contrib/examples/publish.conf.example
Normal file
15
contrib/examples/publish.conf.example
Normal 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=""
|
||||||
13
contrib/systemd/user/mpris-scrobbler.service
Normal file
13
contrib/systemd/user/mpris-scrobbler.service
Normal 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
186
install.sh
Executable 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
147
mpris-scrobbler-publish.sh
Executable 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
|
||||||
1283
src/enrich.rs
Normal file
1283
src/enrich.rs
Normal file
File diff suppressed because it is too large
Load Diff
562
src/main.rs
Normal file
562
src/main.rs
Normal 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
1840
src/report.rs
Normal file
File diff suppressed because it is too large
Load Diff
890
src/watcher.rs
Normal file
890
src/watcher.rs
Normal 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
125
uninstall.sh
Executable 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."
|
||||||
Reference in New Issue
Block a user