feat: write playlists into the mirror from the listening history
Build and publish container / build (pull_request) Successful in 5m13s

Six rules over the scrobble history and the library index, written as extended
M3U into <mirror>/_playlists/ and rebuilt every pass. Three look at what gets
played -- heavy rotation, all-time, and things played heavily once and silent
for a year. Three look at what does not: album tracks skipped on records
otherwise played constantly, unplayed tracks by the artists played most, and
unplayed tracks from anywhere. Four fifths of the library has never been played,
so both halves are needed.

Ninety days was the obvious window for "recent" and is the wrong one. On a real
history it holds a few hundred plays spread across a twenty-thousand track
rotation, and nothing ranks meaningfully. Twelve months does.

The two rotating playlists are shuffled by week rather than by pass. A pass runs
every few hours; a playlist that reorders itself each time is one that has to be
re-imported each time, because the Music app imports a snapshot of a file rather
than tracking it.

Lidarr knows where the lossless source is, and the playlists have to point at
the MP3s music-mirror produced from it. The library root is derived from the
common parent of the indexed artist folders, so it agrees with Lidarr by
construction instead of being kept in step by hand, and can be overridden.
Entries are relative to the playlist file, so one file works from the NAS, from
a Mac over SMB and from Linux.

A track is listed only once its mirror file has been confirmed to exist: Lidarr
holding the FLAC says nothing about whether the MP3 has been encoded. When many
are missing the run says so, because that is what a misconfigured root looks
like -- every path mapping to nothing -- and silence there would present an
empty playlist as a correct one.
This commit is contained in:
Emma Thorpe
2026-08-24 17:50:58 +01:00
parent 08f099aaa9
commit cda3d8463b
4 changed files with 491 additions and 6 deletions
+54 -4
View File
@@ -7,9 +7,10 @@ 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 two.** It ingests the scrobble history, indexes the library from
Lidarr, and matches one to the other. There are no playlists yet, and it writes
nothing back — every Lidarr call is a `GET`. See "Where this is going" below.
**This is stage three.** It ingests the scrobble history, indexes the library
from Lidarr, matches one to the other, and writes playlists into the mirror.
Nothing is written back to Lidarr — every call there is a `GET`. See "Where this
is going" below.
## What it does today
@@ -18,6 +19,7 @@ nothing back — every Lidarr call is a `GET`. See "Where this 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.
## Matching
@@ -126,6 +128,50 @@ and a shared title is a weak proxy for a shared song:
Only the first is worth chasing. Counting all three as matcher failures
overstates the problem and would over-block the cull.
## Playlists
Written into `<mirror>/_playlists/` as extended M3U, rebuilt every pass. Six
rules, capped at `--playlist-limit` tracks each:
| Playlist | Rule |
| -------------------- | --------------------------------------------------------- |
| `heavy-rotation` | Most played over the last twelve months |
| `all-time` | Most played ever |
| `neglected` | Played heavily once, silent for twelve months |
| `deep-cuts` | Never played, from albums whose other tracks you play constantly |
| `unheard-favourites` | Never played, by the artists you play most |
| `unheard` | Never played, anywhere in the library |
Ninety days was the obvious window for "recent" and is the wrong one: on a real
history it holds a few hundred plays spread thinly across a twenty-thousand
track rotation, so nothing ranks meaningfully. Twelve months does.
The two `unheard` playlists rotate **weekly**, not per pass. A pass runs every
few hours, and a playlist that reorders itself each time is one that has to be
re-imported each time — the Music app imports a snapshot of a file, it does not
track it.
### Paths
Lidarr knows where the lossless source is; the playlists have to point at the
MP3s music-mirror made from it. The mapping strips a library root from Lidarr's
track paths and re-roots them under the mirror, with the suffix changed.
`--library-root` is derived from the common parent of the indexed artist folders
when unset, so it agrees with Lidarr by construction rather than by being kept
in step by hand. Override it if that guess is wrong.
Entries are written **relative to the playlist file**, so one playlist works
from the NAS, from a Mac over SMB, and from Linux, without rewriting.
A track is only listed once its mirror file has been confirmed to exist. Lidarr
holding the FLAC says nothing about whether the MP3 has been encoded yet. If a
large number are missing, the run says so — that is what a wrong `--library-root`
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.
## How the ingest works
Two halves, both taking their bounds from the database rather than from a saved
@@ -180,6 +226,9 @@ music-curator --report-only # report on the store, fetch nothing
| `--backfill-limit` | `MUSIC_CURATOR_BACKFILL_LIMIT` | `0` | Cap backfill requests per pass; 0 for no cap |
| `--lidarr-url` | `MUSIC_CURATOR_LIDARR_URL` | unset | Lidarr base URL, e.g. `http://lidarr:8686` |
| `--lidarr-api-key` | `MUSIC_CURATOR_LIDARR_API_KEY` | unset | Lidarr API key |
| `--mirror` | `MUSIC_CURATOR_MIRROR` | unset | Root of the MP3 mirror; playlists go here |
| `--library-root` | `MUSIC_CURATOR_LIBRARY_ROOT` | derived | Prefix to strip from Lidarr's paths |
| `--playlist-limit` | `MUSIC_CURATOR_PLAYLIST_LIMIT` | `100` | Most tracks in any one playlist |
| `--skip-index` | — | off | Match against the index already held |
| `--report-only` | — | off | Report without fetching |
@@ -228,7 +277,8 @@ nix shell nixpkgs#python3Packages.pytest -c pytest
| ------------------------------------------------ | ------------ |
| Last.fm ingest and store | done |
| Lidarr index and the scrobble-to-track matcher | done |
| M3U playlists written into the mirror | next |
| M3U playlists from the listening history | done |
| Genre and mood playlists from Last.fm tags | next |
| Cold-music report, unmonitoring what is not played | last |
The cull will unmonitor cold albums in Lidarr and tag their artists. It will