2 Commits
Author SHA1 Message Date
lyrathorpe 61761c7fac Merge pull request 'fix: remove playlists left behind by the switch to .m3u8' (#15) from fix/prune-superseded-playlists into main
Build and publish container / build (push) Successful in 2m41s
Reviewed-on: #15
2026-08-26 20:39:09 +01:00
Emma Thorpe 8644184caa fix: remove playlists left behind by the switch to .m3u8
Build and publish container / build (pull_request) Successful in 3m40s
Every playlist appeared twice on the device. Pruning is driven by a record of
what was written last time, deliberately, so that a playlist put in that
directory by hand is never touched. But the record cannot reach back before it
existed: the .m3u files were written by a version that kept none, so when the
extension changed there was nothing to prune them by, and rsync carried both
copies across.

A file with the same name as one being written now, under an extension this
tool used to write, is removed as well. That is narrow enough to match only its
own leavings -- a hand-made playlist has a name this tool never produces, so it
survives whatever its extension, and there is a test for that alongside the one
for the duplicate.
2026-08-26 18:06:47 +01:00
2 changed files with 51 additions and 1 deletions
+19 -1
View File
@@ -75,6 +75,11 @@ PLAYLIST_DIRECTORY = "_playlists"
# library holds Mötley Crüe, Beyoncé and Sigur Rós. # library holds Mötley Crüe, Beyoncé and Sigur Rós.
PLAYLIST_SUFFIX = ".m3u8" PLAYLIST_SUFFIX = ".m3u8"
# Extensions this tool has written in the past. A playlist of the same name
# under one of these is a leftover of its own, and is cleaned up even though no
# record of writing it survives.
SUPERSEDED_SUFFIXES = (".m3u",)
# Tags are re-fetched this often. They move slowly, and the first pass over a # Tags are re-fetched this often. They move slowly, and the first pass over a
# library already costs one request per artist. # library already costs one request per artist.
TAG_REFRESH_SECONDS = 90 * 86400 TAG_REFRESH_SECONDS = 90 * 86400
@@ -1674,9 +1679,22 @@ def prune_playlists(directory, produced, store):
Driven by a record of what was written last time rather than by "every M3U Driven by a record of what was written last time rather than by "every M3U
that is not one of ours", so a playlist put there by hand is left alone. A that is not one of ours", so a playlist put there by hand is left alone. A
renamed mood otherwise leaves its old file on the device for ever. renamed mood otherwise leaves its old file on the device for ever.
That record cannot reach back before it existed, which is how the switch
from .m3u to .m3u8 left every playlist on the device twice: the old files
were written by a version that kept no record, so there was nothing to
prune them by. A file with the same name as one being written now, under a
superseded extension, is therefore removed as well -- narrow enough that
only this tool's own leavings match it.
""" """
previous = set(json.loads(store.get_state("playlist_files") or "[]")) previous = set(json.loads(store.get_state("playlist_files") or "[]"))
for name in sorted(previous - set(produced)): stale_names = previous - set(produced)
for name in produced:
for superseded in SUPERSEDED_SUFFIXES:
stale_names.add(Path(name).with_suffix(superseded).name)
stale_names -= set(produced)
for name in sorted(stale_names):
stale = directory / name stale = directory / name
if stale.is_file(): if stale.is_file():
logger.info("removing playlist %s, no longer produced", name) logger.info("removing playlist %s, no longer produced", name)
+32
View File
@@ -1504,3 +1504,35 @@ def test_the_old_m3u_playlists_are_pruned_after_the_rename(tmp_path):
music_curator.prune_playlists(directory, ["screamo.m3u8"], store) music_curator.prune_playlists(directory, ["screamo.m3u8"], store)
assert not (directory / "screamo.m3u").exists() assert not (directory / "screamo.m3u").exists()
def test_a_playlist_under_a_superseded_extension_is_removed(tmp_path):
"""Switching from .m3u to .m3u8 left every playlist on the device twice.
The old files predated the record of what had been written, so there was
nothing to prune them by."""
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
directory = mirror / "_playlists"
directory.mkdir(parents=True, exist_ok=True)
(directory / "screamo.m3u").write_text("#EXTM3U\n")
(directory / "screamo.m3u8").write_text("#EXTM3U\n")
# No record at all, as on the first run after the rename.
music_curator.prune_playlists(directory, ["screamo.m3u8"], store)
assert not (directory / "screamo.m3u").exists()
assert (directory / "screamo.m3u8").is_file()
def test_an_unrelated_m3u_is_still_left_alone(tmp_path):
"""Only a name this run is writing anyway is matched, so a playlist made by
hand survives whatever its extension."""
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")
music_curator.prune_playlists(directory, ["screamo.m3u8"], store)
assert (directory / "lyras-own-mix.m3u").is_file()