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
+117 -1
View File
@@ -2,6 +2,7 @@ import json
import os
import stat
import urllib.error
from datetime import datetime, timezone
from pathlib import Path
import pytest
@@ -924,9 +925,10 @@ def test_a_track_with_no_mirror_file_is_left_out(tmp_path):
)
music_curator.match_library(store)
total = music_curator.build_playlists(store, mirror, str(source), 100, NOW)
total, produced = music_curator.build_playlists(store, mirror, str(source), 100, NOW)
assert total == 0
assert produced, "the playlists are still written, they are simply empty"
assert (mirror / "_playlists" / "all-time.m3u").read_text() == "#EXTM3U\n"
@@ -1342,3 +1344,117 @@ def test_a_playlist_is_chowned_to_match_the_mirror(tmp_path, monkeypatch):
assert all(tuple(owner) == (568, 568) for _, *owner in attempted)
# The temporary file, before the rename, never the finished playlist.
assert all(path.endswith(".part") or path.endswith("_playlists") for path, *_ in attempted)
def test_a_renamed_mood_does_not_leave_its_old_playlist_behind(tmp_path):
"""Otherwise the dead file stays on the device for ever."""
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
directory = mirror / "_playlists"
directory.mkdir(parents=True, exist_ok=True)
(directory / "high-energy-rock.m3u").write_text("#EXTM3U\n")
(directory / "screamo.m3u").write_text("#EXTM3U\n")
store.set_state(
"playlist_files", json.dumps(["high-energy-rock.m3u", "screamo.m3u"])
)
music_curator.prune_playlists(directory, ["screamo.m3u"], store)
assert not (directory / "high-energy-rock.m3u").exists()
assert (directory / "screamo.m3u").exists()
def test_pruning_leaves_a_playlist_it_never_wrote(tmp_path):
"""A hand-made playlist in that directory is not ours to delete."""
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
directory = mirror / "_playlists"
directory.mkdir(parents=True, exist_ok=True)
(directory / "lyras-own-mix.m3u").write_text("#EXTM3U\n")
store.set_state("playlist_files", json.dumps(["screamo.m3u"]))
music_curator.prune_playlists(directory, [], store)
assert (directory / "lyras-own-mix.m3u").exists()
def cold_store(tmp_path, played=(), added="2020-05-01T12:00:00Z"):
"""A library where nothing is played unless named, indexed and matched."""
library = [
{
"name": "Played Band",
"albums": [{"title": "Known", "tracks": [{"title": "Hit", "added": added}]}],
},
{
"name": "Silent Band",
"albums": [
{"title": "Unknown", "tracks": [{"title": "Never Heard", "added": added}]}
],
},
]
store = store_at(tmp_path)
ingest(store, FakeLastfm([scrobble_of(a, t) for a, t in played]))
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=FakeLidarr(library)), store
)
music_curator.match_library(store)
return store
def test_an_album_with_no_plays_is_cold(tmp_path):
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
cold = music_curator.cold_report(store, NOW)
assert [row["artist"] for row in cold] == ["Silent Band"]
def test_an_album_with_any_play_is_not_cold(tmp_path):
"""A record with one played track is a record that gets played; picking the
rest off it leaves gaps rather than reclaiming anything."""
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
cold = music_curator.cold_report(store, NOW)
assert "Played Band" not in [row["artist"] for row in cold]
def test_a_recent_arrival_is_too_young_to_judge(tmp_path):
"""It has not had a chance to be played yet."""
recent = datetime.fromtimestamp(NOW - 30 * 86400, tz=timezone.utc).isoformat()
store = cold_store(tmp_path, played=[("Played Band", "Hit")], added=recent)
assert music_curator.cold_report(store, NOW) == []
def test_the_cull_refuses_to_run_on_an_incomplete_index(tmp_path):
"""A missing artist makes their played music look unplayed, which is
precisely how this would delete something you like."""
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
store.set_state("index_skipped", "1")
assert music_curator.cull_is_safe(store) is not None
assert music_curator.cold_report(store, NOW) == []
def test_the_cull_refuses_while_the_backfill_is_unfinished(tmp_path):
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
store.set_state("backfill_complete", "no")
assert music_curator.cold_report(store, NOW) == []
def test_the_cold_report_writes_nothing_to_lidarr(tmp_path):
"""Stage four is read-only until the list it produces has been looked at."""
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
before = store.scalar("SELECT COUNT(*) FROM lidarr_album WHERE monitored = 1")
music_curator.cold_report(store, NOW)
assert store.scalar("SELECT COUNT(*) FROM lidarr_album WHERE monitored = 1") == before
def test_human_bytes_reads_at_a_glance():
assert music_curator.human_bytes(0) == "0.0 B"
assert music_curator.human_bytes(1536) == "1.5 KiB"
assert music_curator.human_bytes(3 * 1024**3) == "3.0 GiB"