From 71f4cc181435157cacae4cc5f797c8df7eb534ab Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sun, 23 Nov 2025 09:59:34 -0500 Subject: [PATCH] Remove duplicate tracks --- beetsplug/smartplaylist.py | 14 ++++++-- test/plugins/test_smartplaylist.py | 58 ++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 47d7414f4..5fbc4785b 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -277,16 +277,26 @@ class SmartPlaylistPlugin(BeetsPlugin): items = [] # Handle tuple/list of queries (preserves order) + # Track seen items to avoid duplicates when an item matches + # multiple queries + seen_ids = set() + if isinstance(query, (list, tuple)): for q, sort in query: - items.extend(lib.items(q, sort)) + for item in lib.items(q, sort): + if item.id not in seen_ids: + items.append(item) + seen_ids.add(item.id) elif query: items.extend(lib.items(query, q_sort)) if isinstance(album_query, (list, tuple)): for q, sort in album_query: for album in lib.albums(q, sort): - items.extend(album.items()) + for item in album.items(): + if item.id not in seen_ids: + items.append(item) + seen_ids.add(item.id) elif album_query: for album in lib.albums(album_query, a_q_sort): items.extend(album.items()) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index 1b994c094..ad9f056cc 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -350,11 +350,11 @@ class SmartPlaylistTest(BeetsTestCase): spl = SmartPlaylistPlugin() # Create three mock items - i1 = Mock(path=b"/item1.mp3") + i1 = Mock(path=b"/item1.mp3", id=1) i1.evaluate_template.return_value = "ordered.m3u" - i2 = Mock(path=b"/item2.mp3") + i2 = Mock(path=b"/item2.mp3", id=2) i2.evaluate_template.return_value = "ordered.m3u" - i3 = Mock(path=b"/item3.mp3") + i3 = Mock(path=b"/item3.mp3", id=3) i3.evaluate_template.return_value = "ordered.m3u" lib = Mock() @@ -405,6 +405,58 @@ class SmartPlaylistTest(BeetsTestCase): # Items should be in order: i1, i2, i3 assert content == b"/item1.mp3\n/item2.mp3\n/item3.mp3\n" + def test_playlist_update_multiple_queries_no_duplicates(self): + """Test that items matching multiple queries only appear once.""" + spl = SmartPlaylistPlugin() + + # Create two mock items + i1 = Mock(path=b"/item1.mp3", id=1) + i1.evaluate_template.return_value = "dedup.m3u" + i2 = Mock(path=b"/item2.mp3", id=2) + i2.evaluate_template.return_value = "dedup.m3u" + + lib = Mock() + lib.replacements = CHAR_REPLACE + lib.albums.return_value = [] + + # Set up lib.items so both queries return overlapping items + q1 = Mock() + q2 = Mock() + + def items_side_effect(query, sort): + if query == q1: + return [i1, i2] # Both items match q1 + elif query == q2: + return [i2] # Only i2 matches q2 + return [] + + lib.items.side_effect = items_side_effect + + # Create playlist with multiple queries (stored as tuple) + queries_and_sorts = ((q1, None), (q2, None)) + pl = "dedup.m3u", (queries_and_sorts, None), (None, None) + spl._matched_playlists = [pl] + + dir = mkdtemp() + config["smartplaylist"]["relative_to"] = False + config["smartplaylist"]["playlist_dir"] = str(dir) + try: + spl.update_playlists(lib) + except Exception: + rmtree(syspath(dir)) + raise + + m3u_filepath = Path(dir, "dedup.m3u") + assert m3u_filepath.exists() + content = m3u_filepath.read_bytes() + rmtree(syspath(dir)) + + # i2 should only appear once even though it matches both queries + # Order should be: i1 (from q1), i2 (from q1, skipped in q2) + assert content == b"/item1.mp3\n/item2.mp3\n" + # Verify i2 is not duplicated + assert content.count(b"/item2.mp3") == 1 + class SmartPlaylistCLITest(PluginTestCase): plugin = "smartplaylist"