Merge branch 'master' into upgrade2

This commit is contained in:
Alok Saboo
2026-08-05 08:57:42 -04:00
committed by GitHub
14 changed files with 98 additions and 73 deletions

View File

@@ -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()
)

View File

@@ -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)

View File

@@ -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")),
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()]

View File

@@ -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(

View File

@@ -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, {}

View File

@@ -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)

View File

@@ -45,6 +45,13 @@ Bug fixes
Field names are lowercased when a query is parsed, so such attributes could
never be matched; flexible attribute lookups now fall back to a
case-insensitive key match. :bug:`4565`
- :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

View File

@@ -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"'

View File

@@ -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")

View File

@@ -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:

View File

@@ -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