From 8644184caaa0bf33fc26cbbd864c07f60559908f Mon Sep 17 00:00:00 2001 From: Emma Thorpe Date: Wed, 26 Aug 2026 18:06:47 +0100 Subject: [PATCH] fix: remove playlists left behind by the switch to .m3u8 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. --- music_curator.py | 20 +++++++++++++++++++- tests/test_music_curator.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/music_curator.py b/music_curator.py index dd7696a..f8d5d47 100644 --- a/music_curator.py +++ b/music_curator.py @@ -75,6 +75,11 @@ PLAYLIST_DIRECTORY = "_playlists" # library holds Mötley Crüe, Beyoncé and Sigur Rós. 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 # library already costs one request per artist. 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 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. + + 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 "[]")) - 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 if stale.is_file(): logger.info("removing playlist %s, no longer produced", name) diff --git a/tests/test_music_curator.py b/tests/test_music_curator.py index 788ff77..dde613d 100644 --- a/tests/test_music_curator.py +++ b/tests/test_music_curator.py @@ -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) 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() -- 2.54.0