This commit is contained in:
Alok Saboo 2025-12-03 20:00:48 -06:00 committed by GitHub
commit 8b34d066d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 247 additions and 84 deletions

View file

@ -14,14 +14,16 @@
"""Generates smart playlists based on beets queries.""" """Generates smart playlists based on beets queries."""
from __future__ import annotations
import os import os
from typing import Any, TypeAlias
from urllib.parse import quote from urllib.parse import quote
from urllib.request import pathname2url from urllib.request import pathname2url
from beets import ui from beets import ui
from beets.dbcore import OrQuery from beets.dbcore.query import ParsingError, Query, Sort
from beets.dbcore.query import MultipleSort, ParsingError from beets.library import Album, Item, Library, parse_query_string
from beets.library import Album, Item, parse_query_string
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.plugins import send as send_event from beets.plugins import send as send_event
from beets.util import ( from beets.util import (
@ -34,9 +36,17 @@ from beets.util import (
syspath, syspath,
) )
QueryAndSort = tuple[Query, Sort]
PlaylistQuery = Query | tuple[QueryAndSort, ...] | None
PlaylistMatch: TypeAlias = tuple[
str,
tuple[PlaylistQuery, Sort | None],
tuple[PlaylistQuery, Sort | None],
]
class SmartPlaylistPlugin(BeetsPlugin): class SmartPlaylistPlugin(BeetsPlugin):
def __init__(self): def __init__(self) -> None:
super().__init__() super().__init__()
self.config.add( self.config.add(
{ {
@ -55,13 +65,13 @@ class SmartPlaylistPlugin(BeetsPlugin):
) )
self.config["prefix"].redact = True # May contain username/password. self.config["prefix"].redact = True # May contain username/password.
self._matched_playlists = None self._matched_playlists: set[PlaylistMatch] = set()
self._unmatched_playlists = None self._unmatched_playlists: set[PlaylistMatch] = set()
if self.config["auto"]: if self.config["auto"]:
self.register_listener("database_change", self.db_change) self.register_listener("database_change", self.db_change)
def commands(self): def commands(self) -> list[ui.Subcommand]:
spl_update = ui.Subcommand( spl_update = ui.Subcommand(
"splupdate", "splupdate",
help="update the smart playlists. Playlist names may be " help="update the smart playlists. Playlist names may be "
@ -124,18 +134,18 @@ class SmartPlaylistPlugin(BeetsPlugin):
spl_update.func = self.update_cmd spl_update.func = self.update_cmd
return [spl_update] return [spl_update]
def update_cmd(self, lib, opts, args): def update_cmd(self, lib: Library, opts: Any, args: list[str]) -> None:
self.build_queries() self.build_queries()
if args: if args:
args = set(args) args_set = set(args)
for a in list(args): for a in list(args_set):
if not a.endswith(".m3u"): if not a.endswith(".m3u"):
args.add(f"{a}.m3u") args_set.add(f"{a}.m3u")
playlists = { playlists = {
(name, q, a_q) (name, q, a_q)
for name, q, a_q in self._unmatched_playlists for name, q, a_q in self._unmatched_playlists
if name in args if name in args_set
} }
if not playlists: if not playlists:
unmatched = [name for name, _, _ in self._unmatched_playlists] unmatched = [name for name, _, _ in self._unmatched_playlists]
@ -151,16 +161,32 @@ class SmartPlaylistPlugin(BeetsPlugin):
self.__apply_opts_to_config(opts) self.__apply_opts_to_config(opts)
self.update_playlists(lib, opts.pretend) self.update_playlists(lib, opts.pretend)
def __apply_opts_to_config(self, opts): def __apply_opts_to_config(self, opts: Any) -> None:
for k, v in opts.__dict__.items(): for k, v in opts.__dict__.items():
if v is not None and k in self.config: if v is not None and k in self.config:
self.config[k] = v self.config[k] = v
def build_queries(self): def _parse_one_query(
self, playlist: dict[str, Any], key: str, model_cls: type
) -> tuple[PlaylistQuery, Sort | None]:
qs = playlist.get(key)
if qs is None:
return None, None
if isinstance(qs, str):
return parse_query_string(qs, model_cls)
if len(qs) == 1:
return parse_query_string(qs[0], model_cls)
queries_and_sorts: tuple[QueryAndSort, ...] = tuple(
parse_query_string(q, model_cls) for q in qs
)
return queries_and_sorts, None
def build_queries(self) -> None:
""" """
Instantiate queries for the playlists. Instantiate queries for the playlists.
Each playlist has 2 queries: one or items one for albums, each with a Each playlist has 2 queries: one for items, one for albums, each with a
sort. We must also remember its name. _unmatched_playlists is a set of sort. We must also remember its name. _unmatched_playlists is a set of
tuples (name, (q, q_sort), (album_q, album_q_sort)). tuples (name, (q, q_sort), (album_q, album_q_sort)).
@ -169,7 +195,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
More precisely More precisely
- it will be NullSort when a playlist query ('query' or 'album_query') - it will be NullSort when a playlist query ('query' or 'album_query')
is a single item or a list with 1 element is a single item or a list with 1 element
- it will be None when there are multiple items i a query - it will be None when there are multiple items in a query
""" """
self._unmatched_playlists = set() self._unmatched_playlists = set()
self._matched_playlists = set() self._matched_playlists = set()
@ -179,55 +205,37 @@ class SmartPlaylistPlugin(BeetsPlugin):
self._log.warning("playlist configuration is missing name") self._log.warning("playlist configuration is missing name")
continue continue
playlist_data = (playlist["name"],)
try: try:
for key, model_cls in (("query", Item), ("album_query", Album)): q_match = self._parse_one_query(playlist, "query", Item)
qs = playlist.get(key) a_match = self._parse_one_query(playlist, "album_query", Album)
if qs is None:
query_and_sort = None, None
elif isinstance(qs, str):
query_and_sort = parse_query_string(qs, model_cls)
elif len(qs) == 1:
query_and_sort = parse_query_string(qs[0], model_cls)
else:
# multiple queries and sorts
queries, sorts = zip(
*(parse_query_string(q, model_cls) for q in qs)
)
query = OrQuery(queries)
final_sorts = []
for s in sorts:
if s:
if isinstance(s, MultipleSort):
final_sorts += s.sorts
else:
final_sorts.append(s)
if not final_sorts:
sort = None
elif len(final_sorts) == 1:
(sort,) = final_sorts
else:
sort = MultipleSort(final_sorts)
query_and_sort = query, sort
playlist_data += (query_and_sort,)
except ParsingError as exc: except ParsingError as exc:
self._log.warning( self._log.warning(
"invalid query in playlist {}: {}", playlist["name"], exc "invalid query in playlist {}: {}", playlist["name"], exc
) )
continue continue
self._unmatched_playlists.add(playlist_data) self._unmatched_playlists.add((playlist["name"], q_match, a_match))
def matches(self, model, query, album_query): def _matches_query(self, model: Item | Album, query: PlaylistQuery) -> bool:
if album_query and isinstance(model, Album): if not query:
return album_query.match(model) return False
if query and isinstance(model, Item): if isinstance(query, (list, tuple)):
return any(q.match(model) for q, _ in query)
return query.match(model) return query.match(model)
def matches(
self,
model: Item | Album,
query: PlaylistQuery,
album_query: PlaylistQuery,
) -> bool:
if isinstance(model, Album):
return self._matches_query(model, album_query)
if isinstance(model, Item):
return self._matches_query(model, query)
return False return False
def db_change(self, lib, model): def db_change(self, lib: Library, model: Item | Album) -> None:
if self._unmatched_playlists is None: if self._unmatched_playlists is None:
self.build_queries() self.build_queries()
@ -240,7 +248,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
self._unmatched_playlists -= self._matched_playlists self._unmatched_playlists -= self._matched_playlists
def update_playlists(self, lib, pretend=False): def update_playlists(self, lib: Library, pretend: bool = False) -> None:
if pretend: if pretend:
self._log.info( self._log.info(
"Showing query results for {} smart playlists...", "Showing query results for {} smart playlists...",
@ -260,7 +268,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
relative_to = normpath(relative_to) relative_to = normpath(relative_to)
# Maps playlist filenames to lists of track filenames. # Maps playlist filenames to lists of track filenames.
m3us = {} m3us: dict[str, list[PlaylistItem]] = {}
for playlist in self._matched_playlists: for playlist in self._matched_playlists:
name, (query, q_sort), (album_query, a_q_sort) = playlist name, (query, q_sort), (album_query, a_q_sort) = playlist
@ -270,9 +278,28 @@ class SmartPlaylistPlugin(BeetsPlugin):
self._log.info("Creating playlist {}", name) self._log.info("Creating playlist {}", name)
items = [] items = []
if query: # 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:
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)) items.extend(lib.items(query, q_sort))
if album_query:
if isinstance(album_query, (list, tuple)):
for q, sort in album_query:
for album in lib.albums(q, sort):
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): for album in lib.albums(album_query, a_q_sort):
items.extend(album.items()) items.extend(album.items())
@ -292,7 +319,9 @@ class SmartPlaylistPlugin(BeetsPlugin):
if self.config["forward_slash"].get(): if self.config["forward_slash"].get():
item_uri = path_as_posix(item_uri) item_uri = path_as_posix(item_uri)
if self.config["urlencode"]: if self.config["urlencode"]:
item_uri = bytestring_path(pathname2url(item_uri)) item_uri = bytestring_path(
pathname2url(os.fsdecode(item_uri))
)
item_uri = prefix + item_uri item_uri = prefix + item_uri
if item_uri not in m3us[m3u_name]: if item_uri not in m3us[m3u_name]:
@ -336,7 +365,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
) )
f.write(comment.encode("utf-8") + entry.uri + b"\n") f.write(comment.encode("utf-8") + entry.uri + b"\n")
# Send an event when playlists were updated. # Send an event when playlists were updated.
send_event("smartplaylist_update") send_event("smartplaylist_update") # type: ignore
if pretend: if pretend:
self._log.info( self._log.info(
@ -348,6 +377,6 @@ class SmartPlaylistPlugin(BeetsPlugin):
class PlaylistItem: class PlaylistItem:
def __init__(self, item, uri): def __init__(self, item: Item, uri: bytes) -> None:
self.item = item self.item = item
self.uri = uri self.uri = uri

View file

@ -34,6 +34,10 @@ New features:
Bug fixes: Bug fixes:
- :doc:`/plugins/smartplaylist`: Fixed an issue where multiple queries in a
playlist configuration were not preserving their order, causing items to
appear in database order rather than the order specified in the config.
:bug:`6183`
- :doc:`plugins/inline`: Fix recursion error when an inline field definition - :doc:`plugins/inline`: Fix recursion error when an inline field definition
shadows a built-in item field (e.g., redefining ``track_no``). Inline shadows a built-in item field (e.g., redefining ``track_no``). Inline
expressions now skip self-references during evaluation to avoid infinite expressions now skip self-references during evaluation to avoid infinite

View file

@ -22,7 +22,6 @@ from unittest.mock import MagicMock, Mock, PropertyMock
import pytest import pytest
from beets import config from beets import config
from beets.dbcore import OrQuery
from beets.dbcore.query import FixedFieldSort, MultipleSort, NullSort from beets.dbcore.query import FixedFieldSort, MultipleSort, NullSort
from beets.library import Album, Item, parse_query_string from beets.library import Album, Item, parse_query_string
from beets.test.helper import BeetsTestCase, PluginTestCase from beets.test.helper import BeetsTestCase, PluginTestCase
@ -34,8 +33,8 @@ from beetsplug.smartplaylist import SmartPlaylistPlugin
class SmartPlaylistTest(BeetsTestCase): class SmartPlaylistTest(BeetsTestCase):
def test_build_queries(self): def test_build_queries(self):
spl = SmartPlaylistPlugin() spl = SmartPlaylistPlugin()
assert spl._matched_playlists is None assert spl._matched_playlists == set()
assert spl._unmatched_playlists is None assert spl._unmatched_playlists == set()
config["smartplaylist"]["playlists"].set([]) config["smartplaylist"]["playlists"].set([])
spl.build_queries() spl.build_queries()
@ -54,16 +53,17 @@ class SmartPlaylistTest(BeetsTestCase):
foo_foo = parse_query_string("FOO foo", Item) foo_foo = parse_query_string("FOO foo", Item)
baz_baz = parse_query_string("BAZ baz", Item) baz_baz = parse_query_string("BAZ baz", Item)
baz_baz2 = parse_query_string("BAZ baz", Album) baz_baz2 = parse_query_string("BAZ baz", Album)
bar_bar = OrQuery( # Multiple queries are now stored as a tuple of (query, sort) tuples
( bar_queries = tuple(
parse_query_string("BAR bar1", Album)[0], [
parse_query_string("BAR bar2", Album)[0], parse_query_string("BAR bar1", Album),
) parse_query_string("BAR bar2", Album),
]
) )
assert spl._unmatched_playlists == { assert spl._unmatched_playlists == {
("foo", foo_foo, (None, None)), ("foo", foo_foo, (None, None)),
("baz", baz_baz, baz_baz2), ("baz", baz_baz, baz_baz2),
("bar", (None, None), (bar_bar, None)), ("bar", (None, None), (bar_queries, None)),
} }
def test_build_queries_with_sorts(self): def test_build_queries_with_sorts(self):
@ -86,19 +86,28 @@ class SmartPlaylistTest(BeetsTestCase):
) )
spl.build_queries() spl.build_queries()
sorts = {name: sort for name, (_, sort), _ in spl._unmatched_playlists}
# Multiple queries now return a tuple of (query, sort) tuples, not combined
sorts = {}
for name, (query_data, sort), _ in spl._unmatched_playlists:
if isinstance(query_data, tuple):
# Tuple of queries - each has its own sort
sorts[name] = [s for _, s in query_data]
else:
sorts[name] = sort
sort = FixedFieldSort # short cut since we're only dealing with this sort = FixedFieldSort # short cut since we're only dealing with this
assert sorts["no_sort"] == NullSort() assert sorts["no_sort"] == NullSort()
assert sorts["one_sort"] == sort("year") assert sorts["one_sort"] == sort("year")
assert sorts["only_empty_sorts"] is None # Multiple queries store individual sorts in the tuple
assert sorts["one_non_empty_sort"] == sort("year") assert all(isinstance(x, NullSort) for x in sorts["only_empty_sorts"])
assert sorts["multiple_sorts"] == MultipleSort( assert sorts["one_non_empty_sort"] == [sort("year"), NullSort()]
[sort("year"), sort("genre", False)] assert sorts["multiple_sorts"] == [sort("year"), sort("genre", False)]
) assert sorts["mixed"] == [
assert sorts["mixed"] == MultipleSort( sort("year"),
[sort("year"), sort("genre"), sort("id", False)] NullSort(),
) MultipleSort([sort("genre"), sort("id", False)]),
]
def test_matches(self): def test_matches(self):
spl = SmartPlaylistPlugin() spl = SmartPlaylistPlugin()
@ -122,6 +131,15 @@ class SmartPlaylistTest(BeetsTestCase):
assert spl.matches(i, query, a_query) assert spl.matches(i, query, a_query)
assert spl.matches(a, query, a_query) assert spl.matches(a, query, a_query)
# Test with list of queries
q1 = Mock()
q1.match.return_value = False
q2 = Mock()
q2.match.side_effect = {i: True}.__getitem__
queries_list = [(q1, None), (q2, None)]
assert spl.matches(i, queries_list, None)
assert not spl.matches(a, queries_list, None)
def test_db_changes(self): def test_db_changes(self):
spl = SmartPlaylistPlugin() spl = SmartPlaylistPlugin()
@ -164,7 +182,7 @@ class SmartPlaylistTest(BeetsTestCase):
q = Mock() q = Mock()
a_q = Mock() a_q = Mock()
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
spl._matched_playlists = [pl] spl._matched_playlists = {pl}
dir = mkdtemp() dir = mkdtemp()
config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["relative_to"] = False
@ -206,7 +224,7 @@ class SmartPlaylistTest(BeetsTestCase):
q = Mock() q = Mock()
a_q = Mock() a_q = Mock()
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
spl._matched_playlists = [pl] spl._matched_playlists = {pl}
dir = mkdtemp() dir = mkdtemp()
config["smartplaylist"]["output"] = "extm3u" config["smartplaylist"]["output"] = "extm3u"
@ -256,7 +274,7 @@ class SmartPlaylistTest(BeetsTestCase):
q = Mock() q = Mock()
a_q = Mock() a_q = Mock()
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
spl._matched_playlists = [pl] spl._matched_playlists = {pl}
dir = mkdtemp() dir = mkdtemp()
config["smartplaylist"]["output"] = "extm3u" config["smartplaylist"]["output"] = "extm3u"
@ -301,7 +319,7 @@ class SmartPlaylistTest(BeetsTestCase):
q = Mock() q = Mock()
a_q = Mock() a_q = Mock()
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
spl._matched_playlists = [pl] spl._matched_playlists = {pl}
dir = mkdtemp() dir = mkdtemp()
tpl = "http://beets:8337/item/$id/file" tpl = "http://beets:8337/item/$id/file"
@ -327,6 +345,118 @@ class SmartPlaylistTest(BeetsTestCase):
assert content == b"http://beets:8337/item/3/file\n" assert content == b"http://beets:8337/item/3/file\n"
def test_playlist_update_multiple_queries_preserve_order(self):
"""Test that multiple queries preserve their order in the playlist."""
spl = SmartPlaylistPlugin()
# Create three mock items
i1 = Mock(path=b"/item1.mp3", id=1)
i1.evaluate_template.return_value = "ordered.m3u"
i2 = Mock(path=b"/item2.mp3", id=2)
i2.evaluate_template.return_value = "ordered.m3u"
i3 = Mock(path=b"/item3.mp3", id=3)
i3.evaluate_template.return_value = "ordered.m3u"
lib = Mock()
lib.replacements = CHAR_REPLACE
lib.albums.return_value = []
# Set up lib.items to return different items for different queries
q1 = Mock()
q2 = Mock()
q3 = Mock()
def items_side_effect(query, sort):
if query == q1:
return [i1]
elif query == q2:
return [i2]
elif query == q3:
return [i3]
return []
lib.items.side_effect = items_side_effect
# Create playlist with multiple queries (stored as tuple)
queries_and_sorts = ((q1, None), (q2, None), (q3, None))
pl = "ordered.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
# Verify that lib.items was called with queries in the correct order
assert lib.items.call_count == 3
lib.items.assert_any_call(q1, None)
lib.items.assert_any_call(q2, None)
lib.items.assert_any_call(q3, None)
m3u_filepath = Path(dir, "ordered.m3u")
assert m3u_filepath.exists()
content = m3u_filepath.read_bytes()
rmtree(syspath(dir))
# 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): class SmartPlaylistCLITest(PluginTestCase):
plugin = "smartplaylist" plugin = "smartplaylist"