Merge branch 'master' into titlecase

This commit is contained in:
Henry 2025-11-22 00:38:51 -08:00
commit 327d237d9e
9 changed files with 83 additions and 26 deletions

View file

@ -151,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
list list
) )
listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list)
template_funcs: TFuncMap[str] | None = None template_funcs: ClassVar[TFuncMap[str]] | TFuncMap[str] = {} # type: ignore[valid-type]
template_fields: TFuncMap[Item] | None = None template_fields: ClassVar[TFuncMap[Item]] | TFuncMap[Item] = {} # type: ignore[valid-type]
album_template_fields: TFuncMap[Album] | None = None album_template_fields: ClassVar[TFuncMap[Album]] | TFuncMap[Album] = {} # type: ignore[valid-type]
name: str name: str
config: ConfigView config: ConfigView
@ -219,8 +219,8 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
self.name = name or self.__module__.split(".")[-1] self.name = name or self.__module__.split(".")[-1]
self.config = beets.config[self.name] self.config = beets.config[self.name]
# Set class attributes if they are not already set # If the class attributes are not set, initialize as instance attributes.
# for the type of plugin. # TODO: Revise with v3.0.0, see also type: ignore[valid-type] above
if not self.template_funcs: if not self.template_funcs:
self.template_funcs = {} self.template_funcs = {}
if not self.template_fields: if not self.template_fields:
@ -368,8 +368,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
""" """
def helper(func: TFunc[str]) -> TFunc[str]: def helper(func: TFunc[str]) -> TFunc[str]:
if cls.template_funcs is None:
cls.template_funcs = {}
cls.template_funcs[name] = func cls.template_funcs[name] = func
return func return func
@ -384,8 +382,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
""" """
def helper(func: TFunc[Item]) -> TFunc[Item]: def helper(func: TFunc[Item]) -> TFunc[Item]:
if cls.template_fields is None:
cls.template_fields = {}
cls.template_fields[name] = func cls.template_fields[name] = func
return func return func
@ -565,8 +561,7 @@ def template_funcs() -> TFuncMap[str]:
""" """
funcs: TFuncMap[str] = {} funcs: TFuncMap[str] = {}
for plugin in find_plugins(): for plugin in find_plugins():
if plugin.template_funcs: funcs.update(plugin.template_funcs)
funcs.update(plugin.template_funcs)
return funcs return funcs
@ -592,21 +587,20 @@ F = TypeVar("F")
def _check_conflicts_and_merge( 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: ) -> None:
"""Check the provided template functions for conflicts and merge into funcs. """Check the provided template functions for conflicts and merge into funcs.
Raises a `PluginConflictError` if a plugin defines template functions Raises a `PluginConflictError` if a plugin defines template functions
for fields that another plugin has already defined template functions for. for fields that another plugin has already defined template functions for.
""" """
if plugin_funcs: if not plugin_funcs.keys().isdisjoint(funcs.keys()):
if not plugin_funcs.keys().isdisjoint(funcs.keys()): conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys()) raise PluginConflictError(
raise PluginConflictError( f"Plugin {plugin.name} defines template functions for "
f"Plugin {plugin.name} defines template functions for " f"{conflicted_fields} that conflict with another plugin."
f"{conflicted_fields} that conflict with another plugin." )
) funcs.update(plugin_funcs)
funcs.update(plugin_funcs)
def item_field_getters() -> TFuncMap[Item]: def item_field_getters() -> TFuncMap[Item]:

View file

@ -27,7 +27,16 @@ import gi
from beets import ui 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 from gi.repository import GLib, Gst # noqa: E402
Gst.init(None) Gst.init(None)

View file

@ -19,11 +19,11 @@ from __future__ import annotations
import re import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from beets import plugins, ui from beets import config, plugins, ui
if TYPE_CHECKING: if TYPE_CHECKING:
from beets.importer import ImportSession, ImportTask from beets.importer import ImportSession, ImportTask
from beets.library import Item from beets.library import Album, Item
def split_on_feat( def split_on_feat(
@ -98,6 +98,11 @@ def find_feat_part(
return 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): class FtInTitlePlugin(plugins.BeetsPlugin):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -129,6 +134,10 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if self.config["auto"]: if self.config["auto"]:
self.import_stages = [self.imported] self.import_stages = [self.imported]
self.album_template_fields["album_artist_no_feat"] = (
_album_artist_no_feat
)
def commands(self) -> list[ui.Subcommand]: def commands(self) -> list[ui.Subcommand]:
def func(lib, opts, args): def func(lib, opts, args):
self.config.set_args(opts) self.config.set_args(opts)

View file

@ -17,9 +17,10 @@
import base64 import base64
import json import json
import os import os
import typing as t
import flask import flask
from flask import g, jsonify from flask import jsonify
from unidecode import unidecode from unidecode import unidecode
from werkzeug.routing import BaseConverter, PathConverter from werkzeug.routing import BaseConverter, PathConverter
@ -28,6 +29,17 @@ from beets import ui, util
from beets.dbcore.query import PathQuery from beets.dbcore.query import PathQuery
from beets.plugins import BeetsPlugin 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. # Utilities.
@ -232,7 +244,7 @@ def _get_unique_table_field_values(model, field, sort_field):
raise KeyError raise KeyError
with g.lib.transaction() as tx: with g.lib.transaction() as tx:
rows = tx.query( 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] return [row[0] for row in rows]

View file

@ -13,6 +13,7 @@ been dropped.
New features: New features:
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. - :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 - :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the
genres tag. genres tag.
- :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and - :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and
@ -45,6 +46,10 @@ Bug fixes:
accepted a list of strings). :bug:`5962` accepted a list of strings). :bug:`5962`
- Fix a bug introduced in release 2.4.0 where import from any valid - 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. 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: For plugin developers:
@ -64,6 +69,8 @@ Other changes:
- Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into - Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into
multiple modules within the ``beets/ui/commands`` directory for better multiple modules within the ``beets/ui/commands`` directory for better
maintainability. 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) 2.5.1 (October 14, 2025)
------------------------ ------------------------

View file

@ -33,6 +33,14 @@ file. The available options are:
- **custom_words**: List of additional words that will be treated as a marker - **custom_words**: List of additional words that will be treated as a marker
for artist features. Default: ``[]``. for artist features. Default: ``[]``.
Path Template Values
--------------------
This plugin provides the ``album_artist_no_feat`` :ref:`template value
<templ_plugins>` 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 Running Manually
---------------- ----------------

View file

@ -281,6 +281,8 @@ constructs include:
- ``$missing`` by :doc:`/plugins/missing`: The number of missing tracks per - ``$missing`` by :doc:`/plugins/missing`: The number of missing tracks per
album. 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 - ``%bucket{text}`` by :doc:`/plugins/bucket`: Substitute a string by the range
it belongs to. it belongs to.
- ``%the{text}`` by :doc:`/plugins/the`: Moves English articles to ends of - ``%the{text}`` by :doc:`/plugins/the`: Moves English articles to ends of

View file

@ -18,7 +18,7 @@ from collections.abc import Generator
import pytest import pytest
from beets.library.models import Item from beets.library.models import Album, Item
from beets.test.helper import PluginTestCase from beets.test.helper import PluginTestCase
from beetsplug import ftintitle from beetsplug import ftintitle
@ -364,3 +364,12 @@ def test_custom_words(
if custom_words is None: if custom_words is None:
custom_words = [] custom_words = []
assert ftintitle.contains_feat(given, custom_words) is expected 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"

View file

@ -118,6 +118,13 @@ class WebPluginTest(ItemInDBTestCase):
assert response.status_code == 200 assert response.status_code == 200
assert len(res_json["items"]) == 3 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): def test_get_single_item_by_id(self):
response = self.client.get("/item/1") response = self.client.get("/item/1")
res_json = json.loads(response.data.decode("utf-8")) res_json = json.loads(response.data.decode("utf-8"))