mirror of
https://github.com/beetbox/beets.git
synced 2026-08-21 01:40:57 -04:00
Merge branch 'master' into upgrade2
This commit is contained in:
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
41
test/plugins/test_deezer.py
Normal file
41
test/plugins/test_deezer.py
Normal 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"'
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user