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