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
+158
View File
@@ -1,5 +1,7 @@
import json
import stat
import urllib.error
from pathlib import Path
import pytest
from conftest import FakeLastfm, FakeLidarr, make_loved, make_tracks
@@ -825,3 +827,159 @@ def test_the_title_index_is_used_for_the_report_lookup(tmp_path):
)
)
assert "lidarr_track_title" in plan, plan
def playlist_library(tmp_path):
"""A library on disk, with mirror MP3s beside the Lidarr source paths."""
source = tmp_path / "music"
mirror = tmp_path / "mirror"
library = [
{
"name": "Played Band",
"albums": [
{
"title": "Known",
"tracks": [{"title": "Hit"}, {"title": "Album Track"}],
}
],
},
{
"name": "Silent Band",
"albums": [{"title": "Unknown", "tracks": [{"title": "Never Heard"}]}],
},
]
api = FakeLidarr(library)
# FakeLidarr invents /music/<artist>/<album>/<title>.flac; put the mirror
# MP3s at the paths music-mirror would have produced from those.
for handle in api.files:
relative = Path(handle["path"]).relative_to("/music")
handle["path"] = str(source / relative)
mp3 = (mirror / relative).with_suffix(".mp3")
mp3.parent.mkdir(parents=True, exist_ok=True)
mp3.write_bytes(b"not really an mp3")
for artist in api.artists:
artist["path"] = str(source / Path(artist["path"]).name)
return api, source, mirror
def test_playlists_are_written_into_the_mirror(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
ingest(store, FakeLastfm([scrobble_of("Played Band", "Hit")] * 1))
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
music_curator.build_playlists(store, mirror, str(source), 100, NOW)
written = sorted(p.name for p in (mirror / "_playlists").glob("*.m3u"))
assert written == [
"all-time.m3u",
"deep-cuts.m3u",
"heavy-rotation.m3u",
"neglected.m3u",
"unheard-favourites.m3u",
"unheard.m3u",
]
played = (mirror / "_playlists" / "all-time.m3u").read_text().splitlines()
assert played[0] == "#EXTM3U"
assert played[1].startswith("#EXTINF:")
assert "Played Band - Hit" in played[1]
# Relative to the playlist file, so the same file works from any mount.
assert played[2] == "../Played Band/Known/Hit.mp3"
assert (mirror / "_playlists" / "all-time.m3u").parent.joinpath(played[2]).resolve().is_file()
def test_an_unplayed_track_lands_in_the_unheard_playlist(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
ingest(store, FakeLastfm([scrobble_of("Played Band", "Hit")]))
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
music_curator.build_playlists(store, mirror, str(source), 100, NOW)
unheard = (mirror / "_playlists" / "unheard.m3u").read_text()
assert "Never Heard" in unheard
assert "Album Track" in unheard
# The one thing that was played must not be in it.
assert "Played Band - Hit\n" not in unheard
def test_a_track_with_no_mirror_file_is_left_out(tmp_path):
"""The index knows what Lidarr holds; whether music-mirror has encoded the
MP3 yet is a different question, and is checked rather than assumed."""
api, source, mirror = playlist_library(tmp_path)
for mp3 in mirror.rglob("*.mp3"):
mp3.unlink()
store = store_at(tmp_path)
ingest(store, FakeLastfm([scrobble_of("Played Band", "Hit")]))
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
total = music_curator.build_playlists(store, mirror, str(source), 100, NOW)
assert total == 0
assert (mirror / "_playlists" / "all-time.m3u").read_text() == "#EXTM3U\n"
def test_the_library_root_is_derived_from_the_artist_folders(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
assert music_curator.library_root_of(store) == str(source)
def test_the_playlist_limit_is_honoured(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
music_curator.build_playlists(store, mirror, str(source), 1, NOW)
unheard = (mirror / "_playlists" / "unheard.m3u").read_text().splitlines()
assert len([line for line in unheard if line.startswith("#EXTINF")]) == 1
def test_the_rotation_moves_weekly_not_every_pass(tmp_path):
"""A playlist that reorders on every pass is one that has to be re-imported
on every pass; the Music app imports a snapshot, it does not track a file."""
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
def unheard_at(when):
music_curator.build_playlists(store, mirror, str(source), 100, when)
return (mirror / "_playlists" / "unheard.m3u").read_text()
same_week = unheard_at(NOW), unheard_at(NOW + 3600)
assert same_week[0] == same_week[1]
def test_playlists_are_group_readable(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
music_curator.build_playlists(store, mirror, str(source), 100, NOW)
for playlist in (mirror / "_playlists").glob("*.m3u"):
assert playlist.stat().st_mode & stat.S_IRGRP, playlist