mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
Merge branch 'master' into titlecase
This commit is contained in:
commit
327d237d9e
9 changed files with 83 additions and 26 deletions
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
------------------------
|
------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue