From 681f27feb69b9d662901f41712274a4a195259b3 Mon Sep 17 00:00:00 2001 From: Piotr Szpetkowski Date: Thu, 30 Jul 2026 21:18:59 +0200 Subject: [PATCH 1/2] fix(deezer): use free text for singleton searches Singleton searches built the query as ` artist:"<artist>"`. Deezer discards unquoted free text as soon as a query contains any field:"value" filter, so that was evaluated as `artist:"<artist>"` alone - every track by the artist, in Deezer's own relevance order, truncated to `search_limit` (default 5). Substituting nonsense for the title returns a byte-identical result set. For any artist with more releases than that window, the track being imported was simply never among the candidates offered. Filtering on the title as well (`track:"<title>" artist:"<artist>"`) is not a fix: `artist:` matches loosely enough to return unrelated artists, so the two filters can intersect to nothing even for a correctly tagged file. `track:"Get Lucky" artist:"Daft Punk"` returns zero results, while the plain free text `Get Lucky Daft Punk` returns the right track first. Measured over 12 tracks at the default `search_limit`, counting the wanted track appearing anywhere in the results: `<title> artist:"..."` 6/12, mean rank 1.50 `track:"..." artist:"..."` 7/12, mean rank 1.00 free text 10/12, mean rank 1.00 Album searches are unchanged; `album:"<name>"` has no equivalent problem. Adds test/plugins/test_deezer.py, which did not exist. --- beetsplug/deezer.py | 17 ++++++++++++--- docs/changelog.rst | 7 +++++++ test/plugins/test_deezer.py | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 test/plugins/test_deezer.py diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 65d98f5d0..6ea172631 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -215,9 +215,20 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): name: str, va_likely: bool, ) -> tuple[str, dict[str, str]]: - query = f'album:"{name}"' if query_type == "album" else name - if query_type == "track" or not va_likely: - query += f' artist:"{artist}"' + if query_type == "album": + query = f'album:"{name}"' + if not va_likely: + query += f' artist:"{artist}"' + else: + # Deezer drops unquoted free text as soon as the query carries any + # field:"value" filter, so `<title> artist:"<artist>"` degenerated + # into "every track by this artist", truncated to `search_limit`. + # The wanted track routinely fell outside that window. Filtering on + # the title instead is no better, because `artist:` is fuzzy enough + # to match unrelated artists ("Pan Da Punk" for "Daft Punk"), so the + # two filters can intersect to nothing even for a well-tagged file. + # Plain free text lets Deezer's own relevance ranking do the work. + query = f"{name} {artist}".strip() return query, {} diff --git a/docs/changelog.rst b/docs/changelog.rst index 8316ff9a3..2702d108f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,6 +30,13 @@ Bug fixes - :doc:`plugins/lyrics`: ``beet lyrics`` no longer crashes with an ``AttributeError`` on tracks that have no stored lyrics when ``force`` is enabled; a missing lyrics body is now treated as empty text. :bug:`6860` +- :doc:`plugins/deezer`: Singleton searches now use plain free text rather than + ``<title> artist:"<artist>"``. Deezer discards unquoted free text as soon as a + query contains any ``field:"value"`` filter, so the old query was evaluated as + ``artist:"<artist>"`` alone -- every track by the artist, in Deezer's own + relevance order and truncated to ``search_limit``. For artists with more + releases than that window, the track being imported was never among the + candidates offered. .. For plugin developers diff --git a/test/plugins/test_deezer.py b/test/plugins/test_deezer.py new file mode 100644 index 000000000..e62514824 --- /dev/null +++ b/test/plugins/test_deezer.py @@ -0,0 +1,41 @@ +import pytest + +from beets.library import Item +from beetsplug.deezer import DeezerPlugin + + +@pytest.fixture +def plugin(): + return DeezerPlugin() + + +class TestSearchQuery: + def test_track_query_is_free_text(self, plugin): + query, filters = plugin.get_search_query_with_filters( + "track", [Item()], "Artist", "Title", False + ) + + assert query == "Title Artist" + assert filters == {} + + def test_track_query_tolerates_missing_artist(self, plugin): + query, _ = plugin.get_search_query_with_filters( + "track", [Item()], "", "Title", False + ) + + assert query == "Title" + + def test_album_query_filters_on_album_and_artist(self, plugin): + query, filters = plugin.get_search_query_with_filters( + "album", [Item()], "Artist", "Album", False + ) + + assert query == 'album:"Album" artist:"Artist"' + assert filters == {} + + def test_album_query_omits_artist_for_various_artists(self, plugin): + query, _ = plugin.get_search_query_with_filters( + "album", [Item()], "Various Artists", "Album", True + ) + + assert query == 'album:"Album"' From 5933fcad859636f303bad6eda1c2347bf4ea2877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Wed, 5 Aug 2026 01:56:59 +0100 Subject: [PATCH 2/2] Refactor template evaluation to handle format strings ONLY - Handle formats as strings ONLY and evaluate them through a shared cached template helper. - This removes the need for conditional logic that acts on Template objects or strings. --- beets/dbcore/db.py | 20 +++++++------------- beets/library/models.py | 19 ++++++------------- beets/test/helper.py | 10 +++------- beets/ui/commands/modify.py | 16 +++++++--------- beets/util/functemplate.py | 6 +++--- beets/util/pathformats.py | 11 ++--------- beetsplug/bench.py | 8 ++------ beetsplug/smartplaylist.py | 2 +- test/plugins/test_smartplaylist.py | 6 +++--- test/ui/test_ui.py | 4 +--- test/util/test_pathformats.py | 4 +--- 11 files changed, 36 insertions(+), 70 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index b1dc981b2..2b32a2076 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -35,8 +35,9 @@ from typing_extensions import ( from unidecode import unidecode import beets +from beets.util.functemplate import get_template -from ..util import cached_classproperty, functemplate +from ..util import cached_classproperty from . import types from .query import MatchQuery, TrueQuery from .sort import NullSort @@ -688,20 +689,13 @@ class Model(ABC, Generic[D]): """ return self._formatter(self, included_keys, for_path) - def evaluate_template( - self, template: str | functemplate.Template, for_path: bool = False - ) -> str: - """Evaluate a template (a string or a `Template` object) using - the object's fields. If `for_path` is true, then no new path - separators will be added to the template. + def evaluate_template(self, fmt: str, for_path: bool = False) -> str: + """Evaluate a format string using the object's fields. + + If `for_path` is true, then no new path separators are added to the template. """ # Perform substitution. - if isinstance(template, str): - t = functemplate.template(template) - else: - # Help out mypy - t = template - return t.substitute( + return get_template(fmt).substitute( self.formatted(for_path=for_path), self._template_funcs() ) diff --git a/beets/library/models.py b/beets/library/models.py index 7d8172014..95b623833 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -26,7 +26,6 @@ from beets.util import ( syspath, ) from beets.util.deprecation import maybe_replace_legacy_field -from beets.util.functemplate import Template, template from beets.util.pathformats import PF_KEY_DEFAULT from .exceptions import FileOperationError, ReadError, WriteError @@ -102,10 +101,9 @@ class LibModel(dbcore.Model["Library"]): super().add(lib) def __format__(self, spec: str) -> str: - if not spec: - spec = beets.config[self._format_config_key].as_str() - assert isinstance(spec, str) - return self.evaluate_template(spec) + return self.evaluate_template( + spec or beets.config[self._format_config_key].as_str() + ) def __str__(self) -> str: return format(self) @@ -546,8 +544,8 @@ class Album(LibModel): image = bytestring_path(image) item_dir = item_dir or self.item_dir() - filename_tmpl = template(beets.config["art_filename"].as_str()) - subpath = self.evaluate_template(filename_tmpl, True) + filename_tmpl = beets.config["art_filename"].as_str() + subpath = self.evaluate_template(filename_tmpl, for_path=True) if beets.config["asciify_paths"]: subpath = util.asciify_path(subpath) subpath = util.sanitize_path(subpath, replacements=self.db.replacements) @@ -1250,13 +1248,8 @@ class Item(LibModel): break else: assert False, "no default path format" - if isinstance(path_format, Template): - subpath_tmpl = path_format - else: - subpath_tmpl = template(path_format) - # Evaluate the selected template. - subpath = self.evaluate_template(subpath_tmpl, True) + subpath = self.evaluate_template(path_format, for_path=True) if beets.config["asciify_paths"]: subpath = util.asciify_path(subpath) diff --git a/beets/test/helper.py b/beets/test/helper.py index 44a0ab1b9..24406ae5e 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -39,7 +39,6 @@ from beets.library import Item, Library from beets.test import _common from beets.ui.commands.import_.session import TerminalImportSession from beets.util import MoveOperation, clean_module_tempdir, syspath -from beets.util.functemplate import template if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence @@ -614,12 +613,9 @@ class ImportHelper(TestHelper, ImporterMixin): super().setup_beets() self.import_media = [] self.lib.path_formats = [ - ("default", template(os.path.join("$artist", "$album", "$title"))), - ("singleton:true", template(os.path.join("singletons", "$title"))), - ( - "comp:true", - template(os.path.join("compilations", "$album", "$title")), - ), + ("default", os.path.join("$artist", "$album", "$title")), + ("singleton:true", os.path.join("singletons", "$title")), + ("comp:true", os.path.join("compilations", "$album", "$title")), ] diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index 18ea099f5..e03b2de2f 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, NamedTuple from beets import library, ui from beets.dbcore import types from beets.exceptions import UserError -from beets.util import functemplate from beets.util.deprecation import maybe_replace_legacy_field from .utils import do_query @@ -69,16 +68,15 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): # objects. ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") changed = [] - templates = {} - for key, mod in mods.items(): - templates[key] = functemplate.template(mod.value) for obj in objs: - obj_mods = {} - for key, mod in mods.items(): - parsed_value = model_cls._parse( - key, obj.evaluate_template(templates[key]) + obj_mods = { + key: mod.apply( + obj, + key, + model_cls._parse(key, obj.evaluate_template(mod.value)), ) - obj_mods[key] = mod.apply(obj, key, parsed_value) + for key, mod in mods.items() + } if print_and_modify(obj, obj_mods, dels) and obj not in changed: changed.append(obj) diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 5547bcb0e..e7451fb3b 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -16,9 +16,9 @@ from __future__ import annotations import ast import dis -import functools import re import types +from functools import lru_cache SYMBOL_DELIM = "$" FUNC_DELIM = "%" @@ -496,8 +496,8 @@ def _parse(template): return Expression(parts) -@functools.lru_cache(maxsize=128) -def template(fmt) -> Template: +@lru_cache(maxsize=128) +def get_template(fmt: str) -> Template: return Template(fmt) diff --git a/beets/util/pathformats.py b/beets/util/pathformats.py index 275f0f2cb..7d1a6faf5 100644 --- a/beets/util/pathformats.py +++ b/beets/util/pathformats.py @@ -2,14 +2,10 @@ from __future__ import annotations from typing import TYPE_CHECKING -from .functemplate import template - if TYPE_CHECKING: import confuse - from .functemplate import Template - - PathFormat = tuple[str, Template] + PathFormat = tuple[str, str] # Special path format key. @@ -25,7 +21,4 @@ def get_path_formats(subview: confuse.Subview) -> list[PathFormat]: part of ``paths``. This keeps inherited defaults such as ``default``, ``comp``, and ``singleton`` available unless they are explicitly replaced. """ - return [ - (PF_KEY_QUERIES.get(q, q), template(v.as_str())) - for q, v in subview.items() - ] + return [(PF_KEY_QUERIES.get(q, q), v.as_str()) for q, v in subview.items()] diff --git a/beetsplug/bench.py b/beetsplug/bench.py index d1f71c7ad..3beca7928 100644 --- a/beetsplug/bench.py +++ b/beetsplug/bench.py @@ -6,7 +6,6 @@ import timeit from beets import importer, plugins, ui from beets.autotag import tag_album from beets.plugins import BeetsPlugin -from beets.util.functemplate import Template from beets.util.pathformats import PF_KEY_DEFAULT from beetsplug._utils import vfs @@ -17,10 +16,7 @@ def aunique_benchmark(lib, prof): # Measure path generation performance with %aunique{} included. lib.path_formats = [ - ( - PF_KEY_DEFAULT, - Template("$albumartist/$album%aunique{}/$track $title"), - ) + (PF_KEY_DEFAULT, "$albumartist/$album%aunique{}/$track $title") ] if prof: cProfile.runctx( @@ -35,7 +31,7 @@ def aunique_benchmark(lib, prof): # And with %aunique replaced with a "cheap" no-op function. lib.path_formats = [ - (PF_KEY_DEFAULT, Template("$albumartist/$album%lower{}/$track $title")) + (PF_KEY_DEFAULT, "$albumartist/$album%lower{}/$track $title") ] if prof: cProfile.runctx( diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 9b0ad6199..77440ac5f 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -389,7 +389,7 @@ class SmartPlaylistPlugin(plugins.BeetsPlugin): # the items and generate the correct m3u file names. matched_items: list[Item] = [] for item in items: - m3u_name = item.evaluate_template(name, True) + m3u_name = item.evaluate_template(name, for_path=True) m3u_name = sanitize_path(m3u_name, lib.replacements) item_uri = self.get_item_uri(item) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index 1d03d6b08..40512049d 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -168,7 +168,7 @@ class SmartPlaylistTest(PlaylistDirMixin, BeetsTestCase): spl = SmartPlaylistPlugin() i = Mock(path=b"/tagada.mp3") - i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode( + i.evaluate_template.side_effect = lambda pl, **__: os.fsdecode( pl ).replace("$title", "ta:ga:da") @@ -203,7 +203,7 @@ class SmartPlaylistTest(PlaylistDirMixin, BeetsTestCase): type(i).title = PropertyMock(return_value="fake title") type(i).length = PropertyMock(return_value=300.123) type(i).path = PropertyMock(return_value=b"/tagada.mp3") - i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode( + i.evaluate_template.side_effect = lambda pl, **__: os.fsdecode( pl ).replace("$title", "ta:ga:da") @@ -246,7 +246,7 @@ class SmartPlaylistTest(PlaylistDirMixin, BeetsTestCase): type(i).path = PropertyMock(return_value=b"/tagada.mp3") a = {"id": 456, "genres": ["Rock", "Pop"]} i.__getitem__.side_effect = a.__getitem__ - i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode( + i.evaluate_template.side_effect = lambda pl, **__: os.fsdecode( pl ).replace("$title", "ta:ga:da") diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py index 065559b0c..3b45b1a1e 100644 --- a/test/ui/test_ui.py +++ b/test/ui/test_ui.py @@ -124,9 +124,7 @@ class ConfigTest(IOMixin, TestPluginTestCase): config.write("paths: {x: y}") self.run_command("test") - key, template = self.test_cmd.lib.path_formats[0] - assert key == "x" - assert template.original == "y" + assert self.test_cmd.lib.path_formats[0] == ("x", "y") def test_nonexistant_db(self): with self.write_config_file() as config: diff --git a/test/util/test_pathformats.py b/test/util/test_pathformats.py index 47846c191..769377a87 100644 --- a/test/util/test_pathformats.py +++ b/test/util/test_pathformats.py @@ -6,9 +6,7 @@ def test_get_path_formats(config): # override the default 'singleton' path and add a new one config["paths"].set({"singleton": "bar", "new": "hello"}) - path_formats = get_path_formats(config["paths"]) - actual_path_formats = [(key, tmpl.original) for key, tmpl in path_formats] - assert actual_path_formats == [ + assert get_path_formats(config["paths"]) == [ ("singleton:true", "bar"), ("new", "hello"), # defaults