From 9c37f94171ab9a9f180c9206a619ad9d0939de0f Mon Sep 17 00:00:00 2001 From: asardaes Date: Sat, 15 Nov 2025 15:59:35 +0100 Subject: [PATCH 1/5] Add album template value in ftintitle plugin --- beetsplug/ftintitle.py | 13 +++++++++++-- docs/changelog.rst | 1 + docs/plugins/ftintitle.rst | 8 ++++++++ docs/reference/pathformat.rst | 2 ++ test/plugins/test_ftintitle.py | 11 ++++++++++- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index dd681a972..ab841a12c 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -19,11 +19,11 @@ from __future__ import annotations import re from typing import TYPE_CHECKING -from beets import plugins, ui +from beets import config, plugins, ui if TYPE_CHECKING: from beets.importer import ImportSession, ImportTask - from beets.library import Item + from beets.library import Album, Item def split_on_feat( @@ -98,6 +98,11 @@ def find_feat_part( return feat_part +def _album_artist_no_feat(album: Album) -> str: + custom_words = config["ftintitle"]["custom_words"].as_str_seq() + return split_on_feat(album["albumartist"], False, list(custom_words))[0] + + class FtInTitlePlugin(plugins.BeetsPlugin): def __init__(self) -> None: super().__init__() @@ -129,6 +134,10 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if self.config["auto"]: self.import_stages = [self.imported] + self.album_template_fields["album_artist_no_feat"] = ( + _album_artist_no_feat + ) + def commands(self) -> list[ui.Subcommand]: def func(lib, opts, args): self.config.set_args(opts) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2f618103f..d95de38c5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,7 @@ been dropped. New features: - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. +- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``. - :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the genres tag. - :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 1d2ec5c20..3dfbfca27 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -33,6 +33,14 @@ file. The available options are: - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. +Path Template Values +-------------------- + +This plugin provides the ``album_artist_no_feat`` :ref:`template value +` that you can use in your :ref:`path-format-config` in +``paths.default``. Any ``custom_words`` in the configuration are taken into +account. + Running Manually ---------------- diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 30871cf55..10dd3ae05 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -281,6 +281,8 @@ constructs include: - ``$missing`` by :doc:`/plugins/missing`: The number of missing tracks per album. +- ``$album_artist_no_feat`` by :doc:`/plugins/ftintitle`: The album artist + without any featured artists - ``%bucket{text}`` by :doc:`/plugins/bucket`: Substitute a string by the range it belongs to. - ``%the{text}`` by :doc:`/plugins/the`: Moves English articles to ends of diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index b4259666d..6f01601e0 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -18,7 +18,7 @@ from collections.abc import Generator import pytest -from beets.library.models import Item +from beets.library.models import Album, Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -364,3 +364,12 @@ def test_custom_words( if custom_words is None: custom_words = [] assert ftintitle.contains_feat(given, custom_words) is expected + + +def test_album_template_value(): + album = Album() + album["albumartist"] = "Foo ft. Bar" + assert ftintitle._album_artist_no_feat(album) == "Foo" + + album["albumartist"] = "Foobar" + assert ftintitle._album_artist_no_feat(album) == "Foobar" From 2eff2d25f580364b17bdac4d2c75cce6b7e39ecc Mon Sep 17 00:00:00 2001 From: asardaes Date: Sat, 15 Nov 2025 16:31:20 +0100 Subject: [PATCH 2/5] Improve typing for template fields and funcs --- beets/plugins.py | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 810df3a45..b5c3d421b 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -151,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - template_funcs: TFuncMap[str] | None = None - template_fields: TFuncMap[Item] | None = None - album_template_fields: TFuncMap[Album] | None = None + template_funcs: ClassVar[TFuncMap[str]] = {} + template_fields: ClassVar[TFuncMap[Item]] = {} + album_template_fields: ClassVar[TFuncMap[Album]] = {} name: str config: ConfigView @@ -222,11 +222,11 @@ class BeetsPlugin(metaclass=abc.ABCMeta): # Set class attributes if they are not already set # for the type of plugin. if not self.template_funcs: - self.template_funcs = {} + self.template_funcs = {} # type: ignore[misc] if not self.template_fields: - self.template_fields = {} + self.template_fields = {} # type: ignore[misc] if not self.album_template_fields: - self.album_template_fields = {} + self.album_template_fields = {} # type: ignore[misc] self.early_import_stages = [] self.import_stages = [] @@ -368,8 +368,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta): """ def helper(func: TFunc[str]) -> TFunc[str]: - if cls.template_funcs is None: - cls.template_funcs = {} cls.template_funcs[name] = func return func @@ -384,8 +382,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta): """ def helper(func: TFunc[Item]) -> TFunc[Item]: - if cls.template_fields is None: - cls.template_fields = {} cls.template_fields[name] = func return func @@ -565,8 +561,7 @@ def template_funcs() -> TFuncMap[str]: """ funcs: TFuncMap[str] = {} for plugin in find_plugins(): - if plugin.template_funcs: - funcs.update(plugin.template_funcs) + funcs.update(plugin.template_funcs) return funcs @@ -592,21 +587,20 @@ F = TypeVar("F") def _check_conflicts_and_merge( - plugin: BeetsPlugin, plugin_funcs: dict[str, F] | None, funcs: dict[str, F] + plugin: BeetsPlugin, plugin_funcs: dict[str, F], funcs: dict[str, F] ) -> None: """Check the provided template functions for conflicts and merge into funcs. Raises a `PluginConflictError` if a plugin defines template functions for fields that another plugin has already defined template functions for. """ - if plugin_funcs: - if not plugin_funcs.keys().isdisjoint(funcs.keys()): - conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys()) - raise PluginConflictError( - f"Plugin {plugin.name} defines template functions for " - f"{conflicted_fields} that conflict with another plugin." - ) - funcs.update(plugin_funcs) + if not plugin_funcs.keys().isdisjoint(funcs.keys()): + conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys()) + raise PluginConflictError( + f"Plugin {plugin.name} defines template functions for " + f"{conflicted_fields} that conflict with another plugin." + ) + funcs.update(plugin_funcs) def item_field_getters() -> TFuncMap[Item]: From 23a19e94097d40748420c1c13c5078e3d57f73fd Mon Sep 17 00:00:00 2001 From: asardaes Date: Thu, 20 Nov 2025 20:23:30 +0100 Subject: [PATCH 3/5] Remove class variables for template fields and funcs --- beets/plugins.py | 45 +++++++-------------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index b5c3d421b..990fe0874 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -151,9 +151,10 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - template_funcs: ClassVar[TFuncMap[str]] = {} - template_fields: ClassVar[TFuncMap[Item]] = {} - album_template_fields: ClassVar[TFuncMap[Album]] = {} + + template_funcs: TFuncMap[str] + template_fields: TFuncMap[Item] + album_template_fields: TFuncMap[Album] name: str config: ConfigView @@ -219,14 +220,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self.name = name or self.__module__.split(".")[-1] self.config = beets.config[self.name] - # Set class attributes if they are not already set - # for the type of plugin. - if not self.template_funcs: - self.template_funcs = {} # type: ignore[misc] - if not self.template_fields: - self.template_fields = {} # type: ignore[misc] - if not self.album_template_fields: - self.album_template_fields = {} # type: ignore[misc] + self.template_funcs = {} + self.template_fields = {} + self.album_template_fields = {} self.early_import_stages = [] self.import_stages = [] @@ -360,33 +356,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self._set_log_level_and_params(logging.WARNING, func) ) - @classmethod - def template_func(cls, name: str) -> Callable[[TFunc[str]], TFunc[str]]: - """Decorator that registers a path template function. The - function will be invoked as ``%name{}`` from path format - strings. - """ - - def helper(func: TFunc[str]) -> TFunc[str]: - cls.template_funcs[name] = func - return func - - return helper - - @classmethod - def template_field(cls, name: str) -> Callable[[TFunc[Item]], TFunc[Item]]: - """Decorator that registers a path template field computation. - The value will be referenced as ``$name`` from path format - strings. The function must accept a single parameter, the Item - being formatted. - """ - - def helper(func: TFunc[Item]) -> TFunc[Item]: - cls.template_fields[name] = func - return func - - return helper - def get_plugin_names() -> list[str]: """Discover and return the set of plugin names to be loaded. From be0b71043cb0f0fa1cd58555bb2f3efb4f7739a8 Mon Sep 17 00:00:00 2001 From: asardaes Date: Thu, 20 Nov 2025 21:54:25 +0100 Subject: [PATCH 4/5] Revert "Remove class variables for template fields and funcs" This reverts commit a7033fe63b3e039f6ebf23238e9b2257adb0f352. --- beets/plugins.py | 45 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 990fe0874..b5c3d421b 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -151,10 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - - template_funcs: TFuncMap[str] - template_fields: TFuncMap[Item] - album_template_fields: TFuncMap[Album] + template_funcs: ClassVar[TFuncMap[str]] = {} + template_fields: ClassVar[TFuncMap[Item]] = {} + album_template_fields: ClassVar[TFuncMap[Album]] = {} name: str config: ConfigView @@ -220,9 +219,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self.name = name or self.__module__.split(".")[-1] self.config = beets.config[self.name] - self.template_funcs = {} - self.template_fields = {} - self.album_template_fields = {} + # Set class attributes if they are not already set + # for the type of plugin. + if not self.template_funcs: + self.template_funcs = {} # type: ignore[misc] + if not self.template_fields: + self.template_fields = {} # type: ignore[misc] + if not self.album_template_fields: + self.album_template_fields = {} # type: ignore[misc] self.early_import_stages = [] self.import_stages = [] @@ -356,6 +360,33 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self._set_log_level_and_params(logging.WARNING, func) ) + @classmethod + def template_func(cls, name: str) -> Callable[[TFunc[str]], TFunc[str]]: + """Decorator that registers a path template function. The + function will be invoked as ``%name{}`` from path format + strings. + """ + + def helper(func: TFunc[str]) -> TFunc[str]: + cls.template_funcs[name] = func + return func + + return helper + + @classmethod + def template_field(cls, name: str) -> Callable[[TFunc[Item]], TFunc[Item]]: + """Decorator that registers a path template field computation. + The value will be referenced as ``$name`` from path format + strings. The function must accept a single parameter, the Item + being formatted. + """ + + def helper(func: TFunc[Item]) -> TFunc[Item]: + cls.template_fields[name] = func + return func + + return helper + def get_plugin_names() -> list[str]: """Discover and return the set of plugin names to be loaded. From ba18ee2f1461910caed070fd5af674a953201875 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Fri, 21 Nov 2025 17:58:50 +0100 Subject: [PATCH 5/5] Added comment for deprecation in 3.0.0. --- beets/plugins.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index b5c3d421b..0c7bae234 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -151,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - template_funcs: ClassVar[TFuncMap[str]] = {} - template_fields: ClassVar[TFuncMap[Item]] = {} - album_template_fields: ClassVar[TFuncMap[Album]] = {} + template_funcs: ClassVar[TFuncMap[str]] | TFuncMap[str] = {} # type: ignore[valid-type] + template_fields: ClassVar[TFuncMap[Item]] | TFuncMap[Item] = {} # type: ignore[valid-type] + album_template_fields: ClassVar[TFuncMap[Album]] | TFuncMap[Album] = {} # type: ignore[valid-type] name: str config: ConfigView @@ -219,14 +219,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self.name = name or self.__module__.split(".")[-1] self.config = beets.config[self.name] - # Set class attributes if they are not already set - # for the type of plugin. + # If the class attributes are not set, initialize as instance attributes. + # TODO: Revise with v3.0.0, see also type: ignore[valid-type] above if not self.template_funcs: - self.template_funcs = {} # type: ignore[misc] + self.template_funcs = {} if not self.template_fields: - self.template_fields = {} # type: ignore[misc] + self.template_fields = {} if not self.album_template_fields: - self.album_template_fields = {} # type: ignore[misc] + self.album_template_fields = {} self.early_import_stages = [] self.import_stages = []