From 666c412b0ee91b25b08757bc28bbffd0fd249b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20Bastian?= Date: Tue, 11 Nov 2025 21:04:30 +0100 Subject: [PATCH 1/8] =?UTF-8?q?plugins/web:=20fix=20endpoints=20`/?= =?UTF-8?q?=E2=80=A6/values/=E2=80=A6`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following #4709 and #5447, the web plugin used single-quotes (ie. string litteral) in the SQL query for table columns. Thus, for instance, the query `GET /item/values/albumartist` would return the litteral "albumartist" instead of a list of unique album artists. --- beetsplug/web/__init__.py | 2 +- docs/changelog.rst | 4 ++++ test/plugins/test_web.py | 7 +++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 7b13cf016..1fbb3b0f3 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -232,7 +232,7 @@ def _get_unique_table_field_values(model, field, sort_field): raise KeyError with g.lib.transaction() as tx: rows = tx.query( - f"SELECT DISTINCT '{field}' FROM '{model._table}' ORDER BY '{sort_field}'" + f"SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}" ) return [row[0] for row in rows] diff --git a/docs/changelog.rst b/docs/changelog.rst index 366af9ff0..c5a0dab53 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -43,6 +43,10 @@ Bug fixes: accepted a list of strings). :bug:`5962` - Fix a bug introduced in release 2.4.0 where import from any valid import-log-file always threw a "none of the paths are importable" error. +- :doc:`/plugins/web`: repair broken `/item/values/…` and `/albums/values/…` + endpoints. Previously, due to single-quotes (ie. string literal) in the SQL + query, the query eg. `GET /item/values/albumartist` would return the literal + "albumartist" instead of a list of unique album artists. For plugin developers: diff --git a/test/plugins/test_web.py b/test/plugins/test_web.py index 9fc3d109d..4a532e02c 100644 --- a/test/plugins/test_web.py +++ b/test/plugins/test_web.py @@ -118,6 +118,13 @@ class WebPluginTest(ItemInDBTestCase): assert response.status_code == 200 assert len(res_json["items"]) == 3 + def test_get_unique_item_artist(self): + response = self.client.get("/item/values/artist") + res_json = json.loads(response.data.decode("utf-8")) + + assert response.status_code == 200 + assert res_json["values"] == ["", "AAA Singers"] + def test_get_single_item_by_id(self): response = self.client.get("/item/1") res_json = json.loads(response.data.decode("utf-8")) From 189fedb0089ba2bc2fc08fc16dcdca3ce9ac0d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20Bastian?= Date: Sat, 15 Nov 2025 21:00:02 +0100 Subject: [PATCH 2/8] Web plugin: add type hint for g.lib --- beetsplug/web/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 1fbb3b0f3..28bc20152 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -17,9 +17,10 @@ import base64 import json import os +import typing as t import flask -from flask import g, jsonify +from flask import jsonify from unidecode import unidecode from werkzeug.routing import BaseConverter, PathConverter @@ -28,6 +29,17 @@ from beets import ui, util from beets.dbcore.query import PathQuery from beets.plugins import BeetsPlugin +# Type checking hacks + +if t.TYPE_CHECKING: + + class LibraryCtx(flask.ctx._AppCtxGlobals): + lib: beets.library.Library + + g = LibraryCtx() +else: + from flask import g + # Utilities. From aa2dc9005f356b707bc5854848afd23fda0f4f5a Mon Sep 17 00:00:00 2001 From: Ognyan Moore Date: Tue, 18 Nov 2025 23:00:42 +0300 Subject: [PATCH 3/8] Catch ValueError when setting gst required version pytest.importskip is used to catch the case when beetsplug.bpd cannot be imported. On macOS, the gi module was able to be imported, but when trying to specify `gi.require_version`, a ValueError is raised about Gst being unavailable. pytest does not catch this ValueError during importskip as it is not an ImportError, and thus the test suite errors during the test collection phase. With this change, we catch the ValueError, and re-raise it as an ImportError and pytest gracefully skips those tests. --- beetsplug/bpd/gstplayer.py | 11 ++++++++++- docs/changelog.rst | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index fa23f2b0e..f356b3066 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -27,7 +27,16 @@ import gi from beets import ui -gi.require_version("Gst", "1.0") +try: + gi.require_version("Gst", "1.0") +except ValueError as e: + # on some scenarios, gi may be importable, but we get a ValueError when + # trying to specify the required version. This is problematic in the test + # suite where test_bpd.py has a call to + # pytest.importorskip("beetsplug.bpd"). Re-raising as an ImportError + # makes it so the test collector functions as inteded. + raise ImportError from e + from gi.repository import GLib, Gst # noqa: E402 Gst.init(None) diff --git a/docs/changelog.rst b/docs/changelog.rst index c5a0dab53..2f618103f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,6 +66,8 @@ Other changes: - Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into multiple modules within the ``beets/ui/commands`` directory for better maintainability. +- :doc:`plugins/bpd`: Raise ImportError instead of ValueError when GStreamer is + unavailable, enabling ``importorskip`` usage in pytest setup. 2.5.1 (October 14, 2025) ------------------------ From 9c37f94171ab9a9f180c9206a619ad9d0939de0f Mon Sep 17 00:00:00 2001 From: asardaes Date: Sat, 15 Nov 2025 15:59:35 +0100 Subject: [PATCH 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 = []