feat: report which albums have never been played
Build and publish container / build (pull_request) Successful in 6m37s

Stage four, read-only. An album is cold when it has files, not one of its tracks
has ever been scrobbled across the whole nine-year history, and its newest file
landed over a year ago. The age floor is measured from the file rather than the
release date: what matters is how long a record has been available to play, not
how old it is.

Album-level rather than track-level. A record with two played tracks is a record
that gets played, and picking the other ten off it leaves gaps in an album
rather than reclaiming anything worth having.

Track file sizes are now indexed so the report can rank by the disk actually
recoverable, which is the point of the exercise. That needed a column on a table
that already exists, so the store gained an in-place column migration; a full
rebuild would mean re-downloading sixty thousand scrobbles.

The report refuses to produce anything when the backfill is unfinished, when any
artist failed to index, when any artist has no albums, or when there is no
history to judge against. Each of those makes played music look unplayed, which
is the one failure that costs a library, and they are checked rather than
trusted because the report they gate is the one that ends in deletion.

Nothing is written to Lidarr. Every call there remains a GET, and unmonitoring
waits until the list this produces has been looked at -- no flag protects
against a list that is wrong.

Also prunes playlists no longer produced, which #12 made necessary by renaming
moods: high-energy-rock would otherwise have stayed on the device for ever. It
is driven by a record of what was written last time rather than by deleting
every M3U that is not currently ours, so a playlist put in that directory by
hand is left alone.
This commit is contained in:
Emma Thorpe
2026-08-24 19:21:57 +01:00
parent 7b6f6e0016
commit 9fdd61b648
3 changed files with 371 additions and 19 deletions
+40 -3
View File
@@ -7,7 +7,7 @@ which keeps an MP3 copy of a lossless library for an iPod. This one answers the
question that mirror cannot: which of it is worth carrying, and which of it has
not been played in years.
**This is stage three.** It ingests the scrobble history, indexes the library
**This is stage four, read-only.** It ingests the scrobble history, indexes the library
from Lidarr, matches one to the other, and writes playlists into the mirror —
by listening history and by mood.
Nothing is written back to Lidarr — every call there is a `GET`. See "Where this
@@ -20,7 +20,8 @@ is going" below.
- Indexes every artist, album and track Lidarr knows about, with file paths and
the date each file landed.
- Ties the two together and reports how well it managed.
- Writes M3U playlists into the mirror, from the listening history.
- Writes M3U playlists into the mirror, from the listening history and by mood.
- Reports which albums have never been played. It does not act on that.
## Matching
@@ -276,6 +277,40 @@ or `--mirror` looks like, since the paths then map to nothing at all.
`_playlists/` survives music-mirror's prune: it only deletes `*.mp3`, and its
empty-directory sweep skips a directory holding M3Us.
## The cold report
Which albums have files, have never had a single track played in the whole
history, and have sat there long enough to have had the chance. Ranked by the
disk they occupy, because that is the point of the exercise.
**Album-level, not track-level.** A record with two played tracks is a record
that gets played; picking the other ten off it leaves gaps rather than
reclaiming anything worth having.
The age floor is measured from the newest file in the album, not from the
release date — what matters is how long it has been available to play, not how
old the record is. `--cold-after` sets it, defaulting to a year.
Artists whose *every* album is cold are counted separately. That is a different
proposition from one cold record by somebody otherwise played, and Lidarr can
only tag at artist level anyway.
### What stops it
The report refuses to produce anything at all when:
- the scrobble backfill is unfinished
- any artist failed to index, or any artist has no albums indexed
- the library has not been indexed, or there is no history to judge against
Each of those makes played music look unplayed, which is the single failure that
costs a library. They are checked rather than trusted, because the report they
gate is the one that ends in deletion.
**Nothing is written to Lidarr.** Every call there is still a `GET`. Unmonitoring
comes once the list has been looked at, because no flag protects against a list
that is wrong.
## How the ingest works
Two halves, both taking their bounds from the database rather than from a saved
@@ -335,6 +370,7 @@ music-curator --report-only # report on the store, fetch nothing
| `--playlist-limit` | `MUSIC_CURATOR_PLAYLIST_LIMIT` | `100` | Most tracks in any one playlist |
| `--vibes` | `MUSIC_CURATOR_VIBES` | built-in | JSON file of mood definitions |
| `--tag-limit` | `MUSIC_CURATOR_TAG_LIMIT` | `0` | Cap artist tag lookups per pass |
| `--cold-after` | `MUSIC_CURATOR_COLD_AFTER` | `365` | Days a file must sit unplayed to count as cold |
| `--skip-index` | — | off | Match against the index already held |
| `--report-only` | — | off | Report without fetching |
@@ -385,7 +421,8 @@ nix shell nixpkgs#python3Packages.pytest -c pytest
| Lidarr index and the scrobble-to-track matcher | done |
| M3U playlists from the listening history | done |
| Genre and mood playlists from Last.fm tags | done |
| Cold-music report, unmonitoring what is not played | last |
| Cold-music report | done |
| Unmonitoring what is not played | last |
The cull will unmonitor cold albums in Lidarr and tag their artists. It will
never delete files: Lidarr's `AlbumResource` has no tags at all, so tagging