Merge branch 'master' into ffnp

This commit is contained in:
Vrihub 2025-09-30 17:41:13 +02:00 committed by GitHub
commit 5f9800ec43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 786 additions and 320 deletions

View file

@ -71,3 +71,5 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2
07549ed896d9649562d40b75cd30702e6fa6e975 07549ed896d9649562d40b75cd30702e6fa6e975
# Moved plugin docs Further Reading chapter # Moved plugin docs Further Reading chapter
33f1a5d0bef8ca08be79ee7a0d02a018d502680d 33f1a5d0bef8ca08be79ee7a0d02a018d502680d
# Moved art.py utility module from beets into beetsplug
28aee0fde463f1e18dfdba1994e2bdb80833722f

3
.github/CODEOWNERS vendored
View file

@ -1,2 +1,5 @@
# assign the entire repo to the maintainers team # assign the entire repo to the maintainers team
* @beetbox/maintainers * @beetbox/maintainers
# Specific ownerships:
/beets/metadata_plugins.py @semohr

View file

@ -17,15 +17,17 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import functools
import os import os
import re import re
import sqlite3 import sqlite3
import sys
import threading import threading
import time import time
from abc import ABC from abc import ABC
from collections import defaultdict from collections import defaultdict
from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
from sqlite3 import Connection from sqlite3 import Connection, sqlite_version_info
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic
from typing_extensions import TypeVar # default value support from typing_extensions import TypeVar # default value support
@ -64,6 +66,16 @@ class DBAccessError(Exception):
""" """
class DBCustomFunctionError(Exception):
"""A sqlite function registered by beets failed."""
def __init__(self):
super().__init__(
"beets defined SQLite function failed; "
"see the other errors above for details"
)
class FormattedMapping(Mapping[str, str]): class FormattedMapping(Mapping[str, str]):
"""A `dict`-like formatted view of a model. """A `dict`-like formatted view of a model.
@ -947,6 +959,12 @@ class Transaction:
self._mutated = False self._mutated = False
self.db._db_lock.release() self.db._db_lock.release()
if (
isinstance(exc_value, sqlite3.OperationalError)
and exc_value.args[0] == "user-defined function raised exception"
):
raise DBCustomFunctionError()
def query( def query(
self, statement: str, subvals: Sequence[SQLiteType] = () self, statement: str, subvals: Sequence[SQLiteType] = ()
) -> list[sqlite3.Row]: ) -> list[sqlite3.Row]:
@ -1007,6 +1025,13 @@ class Database:
"sqlite3 must be compiled with multi-threading support" "sqlite3 must be compiled with multi-threading support"
) )
# Print tracebacks for exceptions in user defined functions
# See also `self.add_functions` and `DBCustomFunctionError`.
#
# `if`: use feature detection because PyPy doesn't support this.
if hasattr(sqlite3, "enable_callback_tracebacks"):
sqlite3.enable_callback_tracebacks(True)
self.path = path self.path = path
self.timeout = timeout self.timeout = timeout
@ -1102,9 +1127,16 @@ class Database:
return bytestring return bytestring
conn.create_function("regexp", 2, regexp) create_function = conn.create_function
conn.create_function("unidecode", 1, unidecode) if sys.version_info >= (3, 8) and sqlite_version_info >= (3, 8, 3):
conn.create_function("bytelower", 1, bytelower) # Let sqlite make extra optimizations
create_function = functools.partial(
conn.create_function, deterministic=True
)
create_function("regexp", 2, regexp)
create_function("unidecode", 1, unidecode)
create_function("bytelower", 1, bytelower)
def _close(self): def _close(self):
"""Close the all connections to the underlying SQLite database """Close the all connections to the underlying SQLite database

View file

@ -20,6 +20,8 @@ use {}-style formatting and can interpolate keywords arguments to the logging
calls (`debug`, `info`, etc). calls (`debug`, `info`, etc).
""" """
from __future__ import annotations
import threading import threading
from copy import copy from copy import copy
from logging import ( from logging import (
@ -32,8 +34,10 @@ from logging import (
Handler, Handler,
Logger, Logger,
NullHandler, NullHandler,
RootLogger,
StreamHandler, StreamHandler,
) )
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
__all__ = [ __all__ = [
"DEBUG", "DEBUG",
@ -49,8 +53,20 @@ __all__ = [
"getLogger", "getLogger",
] ]
if TYPE_CHECKING:
T = TypeVar("T")
from types import TracebackType
def logsafe(val): # see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi
_SysExcInfoType = Union[
tuple[type[BaseException], BaseException, Union[TracebackType, None]],
tuple[None, None, None],
]
_ExcInfoType = Union[None, bool, _SysExcInfoType, BaseException]
_ArgsType = Union[tuple[object, ...], Mapping[str, object]]
def _logsafe(val: T) -> str | T:
"""Coerce `bytes` to `str` to avoid crashes solely due to logging. """Coerce `bytes` to `str` to avoid crashes solely due to logging.
This is particularly relevant for bytestring paths. Much of our code This is particularly relevant for bytestring paths. Much of our code
@ -83,40 +99,45 @@ class StrFormatLogger(Logger):
""" """
class _LogMessage: class _LogMessage:
def __init__(self, msg, args, kwargs): def __init__(
self,
msg: str,
args: _ArgsType,
kwargs: dict[str, Any],
):
self.msg = msg self.msg = msg
self.args = args self.args = args
self.kwargs = kwargs self.kwargs = kwargs
def __str__(self): def __str__(self):
args = [logsafe(a) for a in self.args] args = [_logsafe(a) for a in self.args]
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()} kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()}
return self.msg.format(*args, **kwargs) return self.msg.format(*args, **kwargs)
def _log( def _log(
self, self,
level, level: int,
msg, msg: object,
args, args: _ArgsType,
exc_info=None, exc_info: _ExcInfoType = None,
extra=None, extra: Mapping[str, Any] | None = None,
stack_info=False, stack_info: bool = False,
stacklevel: int = 1,
**kwargs, **kwargs,
): ):
"""Log msg.format(*args, **kwargs)""" """Log msg.format(*args, **kwargs)"""
m = self._LogMessage(msg, args, kwargs)
stacklevel = kwargs.pop("stacklevel", 1) if isinstance(msg, str):
stacklevel = {"stacklevel": stacklevel} msg = self._LogMessage(msg, args, kwargs)
return super()._log( return super()._log(
level, level,
m, msg,
(), (),
exc_info=exc_info, exc_info=exc_info,
extra=extra, extra=extra,
stack_info=stack_info, stack_info=stack_info,
**stacklevel, stacklevel=stacklevel,
) )
@ -156,9 +177,12 @@ my_manager = copy(Logger.manager)
my_manager.loggerClass = BeetsLogger my_manager.loggerClass = BeetsLogger
# Override the `getLogger` to use our machinery. @overload
def getLogger(name=None): # noqa def getLogger(name: str) -> BeetsLogger: ...
@overload
def getLogger(name: None = ...) -> RootLogger: ...
def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802
if name: if name:
return my_manager.getLogger(name) return my_manager.getLogger(name) # type: ignore[return-value]
else: else:
return Logger.root return Logger.root

View file

@ -271,10 +271,9 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
"""Returns an artist string (all artists) and an artist_id (the main """Returns an artist string (all artists) and an artist_id (the main
artist) for a list of artist object dicts. artist) for a list of artist object dicts.
For each artist, this function moves articles (such as 'a', 'an', For each artist, this function moves articles (such as 'a', 'an', and 'the')
and 'the') to the front and strips trailing disambiguation numbers. It to the front. It returns a tuple containing the comma-separated string
returns a tuple containing the comma-separated string of all of all normalized artists and the ``id`` of the main/first artist.
normalized artists and the ``id`` of the main/first artist.
Alternatively a keyword can be used to combine artists together into a Alternatively a keyword can be used to combine artists together into a
single string by passing the join_key argument. single string by passing the join_key argument.
@ -298,8 +297,6 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
if not artist_id: if not artist_id:
artist_id = artist[id_key] artist_id = artist[id_key]
name = artist[name_key] name = artist[name_key]
# Strip disambiguation number.
name = re.sub(r" \(\d+\)$", "", name)
# Move articles to the front. # Move articles to the front.
name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I) name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I)
# Use a join keyword if requested and available. # Use a join keyword if requested and available.
@ -371,7 +368,9 @@ class SearchApiMetadataSourcePlugin(
album: str, album: str,
va_likely: bool, va_likely: bool,
) -> Iterable[AlbumInfo]: ) -> Iterable[AlbumInfo]:
query_filters: SearchFilter = {"album": album} query_filters: SearchFilter = {}
if album:
query_filters["album"] = album
if not va_likely: if not va_likely:
query_filters["artist"] = artist query_filters["artist"] = artist
@ -413,7 +412,7 @@ class SearchApiMetadataSourcePlugin(
:return: Query string to be provided to the search API. :return: Query string to be provided to the search API.
""" """
components = [query_string, *(f'{k}:"{v}"' for k, v in filters.items())] components = [query_string, *(f"{k}:'{v}'" for k, v in filters.items())]
query = " ".join(filter(None, components)) query = " ".join(filter(None, components))
if self.config["search_query_ascii"].get(): if self.config["search_query_ascii"].get():

View file

@ -22,6 +22,7 @@ import re
import sys import sys
from collections import defaultdict from collections import defaultdict
from functools import wraps from functools import wraps
from importlib import import_module
from pathlib import Path from pathlib import Path
from types import GenericAlias from types import GenericAlias
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
@ -365,11 +366,11 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
""" """
try: try:
try: try:
namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None) namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}")
except Exception as exc: except Exception as exc:
raise PluginImportError(name) from exc raise PluginImportError(name) from exc
for obj in getattr(namespace, name).__dict__.values(): for obj in namespace.__dict__.values():
if ( if (
inspect.isclass(obj) inspect.isclass(obj)
and not isinstance( and not isinstance(
@ -378,6 +379,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
and issubclass(obj, BeetsPlugin) and issubclass(obj, BeetsPlugin)
and obj != BeetsPlugin and obj != BeetsPlugin
and not inspect.isabstract(obj) and not inspect.isabstract(obj)
# Only consider this plugin's module or submodules to avoid
# conflicts when plugins import other BeetsPlugin classes
and (
obj.__module__ == namespace.__name__
or obj.__module__.startswith(f"{namespace.__name__}.")
)
): ):
return obj() return obj()

View file

View file

@ -25,12 +25,13 @@ from string import Template
import mediafile import mediafile
from confuse import ConfigTypeError, Optional from confuse import ConfigTypeError, Optional
from beets import art, config, plugins, ui, util from beets import config, plugins, ui, util
from beets.library import Item, parse_query_string from beets.library import Item, parse_query_string
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.util import par_map from beets.util import par_map
from beets.util.artresizer import ArtResizer from beets.util.artresizer import ArtResizer
from beets.util.m3u import M3UFile from beets.util.m3u import M3UFile
from beetsplug._utils import art
_fs_lock = threading.Lock() _fs_lock = threading.Lock()
_temp_files = [] # Keep track of temporary transcoded files for deletion. _temp_files = [] # Keep track of temporary transcoded files for deletion.
@ -121,6 +122,7 @@ class ConvertPlugin(BeetsPlugin):
"threads": os.cpu_count(), "threads": os.cpu_count(),
"format": "mp3", "format": "mp3",
"id3v23": "inherit", "id3v23": "inherit",
"write_metadata": True,
"formats": { "formats": {
"aac": { "aac": {
"command": ( "command": (
@ -445,7 +447,8 @@ class ConvertPlugin(BeetsPlugin):
if id3v23 == "inherit": if id3v23 == "inherit":
id3v23 = None id3v23 = None
# Write tags from the database to the converted file. # Write tags from the database to the file if requested
if self.config["write_metadata"].get(bool):
item.try_write(path=converted, id3v23=id3v23) item.try_write(path=converted, id3v23=id3v23)
if keep_new: if keep_new:

View file

@ -76,6 +76,8 @@ TRACK_INDEX_RE = re.compile(
re.VERBOSE, re.VERBOSE,
) )
DISAMBIGUATION_RE = re.compile(r" \(\d+\)")
class ReleaseFormat(TypedDict): class ReleaseFormat(TypedDict):
name: str name: str
@ -96,6 +98,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
"separator": ", ", "separator": ", ",
"index_tracks": False, "index_tracks": False,
"append_style_genre": False, "append_style_genre": False,
"strip_disambiguation": True,
} }
) )
self.config["apikey"].redact = True self.config["apikey"].redact = True
@ -336,7 +339,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
# convenient `.tracklist` property, which will strip out useful artist # convenient `.tracklist` property, which will strip out useful artist
# information and leave us with skeleton `Artist` objects that will # information and leave us with skeleton `Artist` objects that will
# each make an API call just to get the same data back. # each make an API call just to get the same data back.
tracks = self.get_tracks(result.data["tracklist"]) tracks = self.get_tracks(result.data["tracklist"], artist, artist_id)
# Extract information for the optional AlbumInfo fields, if possible. # Extract information for the optional AlbumInfo fields, if possible.
va = result.data["artists"][0].get("name", "").lower() == "various" va = result.data["artists"][0].get("name", "").lower() == "various"
@ -362,15 +365,20 @@ class DiscogsPlugin(MetadataSourcePlugin):
label = catalogno = labelid = None label = catalogno = labelid = None
if result.data.get("labels"): if result.data.get("labels"):
label = result.data["labels"][0].get("name") label = self.strip_disambiguation(
result.data["labels"][0].get("name")
)
catalogno = result.data["labels"][0].get("catno") catalogno = result.data["labels"][0].get("catno")
labelid = result.data["labels"][0].get("id") labelid = result.data["labels"][0].get("id")
cover_art_url = self.select_cover_art(result) cover_art_url = self.select_cover_art(result)
# Additional cleanups (various artists name, catalog number, media). # Additional cleanups
# (various artists name, catalog number, media, disambiguation).
if va: if va:
artist = config["va_name"].as_str() artist = config["va_name"].as_str()
else:
artist = self.strip_disambiguation(artist)
if catalogno == "none": if catalogno == "none":
catalogno = None catalogno = None
# Explicitly set the `media` for the tracks, since it is expected by # Explicitly set the `media` for the tracks, since it is expected by
@ -378,10 +386,6 @@ class DiscogsPlugin(MetadataSourcePlugin):
for track in tracks: for track in tracks:
track.media = media track.media = media
track.medium_total = mediums.count(track.medium) track.medium_total = mediums.count(track.medium)
if not track.artist: # get_track_info often fails to find artist
track.artist = artist
if not track.artist_id:
track.artist_id = artist_id
# Discogs does not have track IDs. Invent our own IDs as proposed # Discogs does not have track IDs. Invent our own IDs as proposed
# in #2336. # in #2336.
track.track_id = f"{album_id}-{track.track_alt}" track.track_id = f"{album_id}-{track.track_alt}"
@ -438,7 +442,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
else: else:
return None return None
def get_tracks(self, tracklist): def get_tracks(self, tracklist, album_artist, album_artist_id):
"""Returns a list of TrackInfo objects for a discogs tracklist.""" """Returns a list of TrackInfo objects for a discogs tracklist."""
try: try:
clean_tracklist = self.coalesce_tracks(tracklist) clean_tracklist = self.coalesce_tracks(tracklist)
@ -463,7 +467,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
# divisions. # divisions.
divisions += next_divisions divisions += next_divisions
del next_divisions[:] del next_divisions[:]
track_info = self.get_track_info(track, index, divisions) track_info = self.get_track_info(
track, index, divisions, album_artist, album_artist_id
)
track_info.track_alt = track["position"] track_info.track_alt = track["position"]
tracks.append(track_info) tracks.append(track_info)
else: else:
@ -622,7 +628,17 @@ class DiscogsPlugin(MetadataSourcePlugin):
return tracklist return tracklist
def get_track_info(self, track, index, divisions): def strip_disambiguation(self, text: str) -> str:
"""Removes discogs specific disambiguations from a string.
Turns 'Label Name (5)' to 'Label Name' or 'Artist (1) & Another Artist (2)'
to 'Artist & Another Artist'. Does nothing if strip_disambiguation is False."""
if not self.config["strip_disambiguation"]:
return text
return DISAMBIGUATION_RE.sub("", text)
def get_track_info(
self, track, index, divisions, album_artist, album_artist_id
):
"""Returns a TrackInfo object for a discogs track.""" """Returns a TrackInfo object for a discogs track."""
title = track["title"] title = track["title"]
if self.config["index_tracks"]: if self.config["index_tracks"]:
@ -634,7 +650,21 @@ class DiscogsPlugin(MetadataSourcePlugin):
artist, artist_id = self.get_artist( artist, artist_id = self.get_artist(
track.get("artists", []), join_key="join" track.get("artists", []), join_key="join"
) )
# If no artist and artist is returned, set to match album artist
if not artist:
artist = album_artist
artist_id = album_artist_id
length = self.get_track_length(track["duration"]) length = self.get_track_length(track["duration"])
# Add featured artists
extraartists = track.get("extraartists", [])
featured = [
artist["name"]
for artist in extraartists
if "Featuring" in artist["role"]
]
if featured:
artist = f"{artist} feat. {', '.join(featured)}"
artist = self.strip_disambiguation(artist)
return TrackInfo( return TrackInfo(
title=title, title=title,
track_id=track_id, track_id=track_id,

View file

@ -20,11 +20,12 @@ from mimetypes import guess_extension
import requests import requests
from beets import art, config, ui from beets import config, ui
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.ui import print_ from beets.ui import print_
from beets.util import bytestring_path, displayable_path, normpath, syspath from beets.util import bytestring_path, displayable_path, normpath, syspath
from beets.util.artresizer import ArtResizer from beets.util.artresizer import ArtResizer
from beetsplug._utils import art
def _confirm(objs, album): def _confirm(objs, album):

View file

@ -36,10 +36,10 @@ from beets.util.config import sanitize_pairs
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence from collections.abc import Iterable, Iterator, Sequence
from logging import Logger
from beets.importer import ImportSession, ImportTask from beets.importer import ImportSession, ImportTask
from beets.library import Album, Library from beets.library import Album, Library
from beets.logging import BeetsLogger as Logger
try: try:
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag

View file

@ -26,14 +26,16 @@ if TYPE_CHECKING:
from beets.library import Item from beets.library import Item
def split_on_feat(artist: str) -> tuple[str, str | None]: def split_on_feat(
artist: str, for_artist: bool = True
) -> tuple[str, str | None]:
"""Given an artist string, split the "main" artist from any artist """Given an artist string, split the "main" artist from any artist
on the right-hand side of a string like "feat". Return the main on the right-hand side of a string like "feat". Return the main
artist, which is always a string, and the featuring artist, which artist, which is always a string, and the featuring artist, which
may be a string or None if none is present. may be a string or None if none is present.
""" """
# split on the first "feat". # split on the first "feat".
regex = re.compile(plugins.feat_tokens(), re.IGNORECASE) regex = re.compile(plugins.feat_tokens(for_artist), re.IGNORECASE)
parts = tuple(s.strip() for s in regex.split(artist, 1)) parts = tuple(s.strip() for s in regex.split(artist, 1))
if len(parts) == 1: if len(parts) == 1:
return parts[0], None return parts[0], None
@ -53,32 +55,35 @@ def contains_feat(title: str) -> bool:
) )
def find_feat_part(artist: str, albumartist: str) -> str | None: def find_feat_part(artist: str, albumartist: str | None) -> str | None:
"""Attempt to find featured artists in the item's artist fields and """Attempt to find featured artists in the item's artist fields and
return the results. Returns None if no featured artist found. return the results. Returns None if no featured artist found.
""" """
# Look for the album artist in the artist field. If it's not # Handle a wider variety of extraction cases if the album artist is
# present, give up. # contained within the track artist.
if albumartist and albumartist in artist:
albumartist_split = artist.split(albumartist, 1) albumartist_split = artist.split(albumartist, 1)
if len(albumartist_split) <= 1:
return None
# If the last element of the split (the right-hand side of the # If the last element of the split (the right-hand side of the
# album artist) is nonempty, then it probably contains the # album artist) is nonempty, then it probably contains the
# featured artist. # featured artist.
elif albumartist_split[1] != "": if albumartist_split[1] != "":
# Extract the featured artist from the right-hand side. # Extract the featured artist from the right-hand side.
_, feat_part = split_on_feat(albumartist_split[1]) _, feat_part = split_on_feat(albumartist_split[1])
return feat_part return feat_part
# Otherwise, if there's nothing on the right-hand side, look for a # Otherwise, if there's nothing on the right-hand side,
# featuring artist on the left-hand side. # look for a featuring artist on the left-hand side.
else: else:
lhs, rhs = split_on_feat(albumartist_split[0]) lhs, _ = split_on_feat(albumartist_split[0])
if lhs: if lhs:
return lhs return lhs
return None # Fall back to conservative handling of the track artist without relying
# on albumartist, which covers compilations using a 'Various Artists'
# albumartist and album tracks by a guest artist featuring a third artist.
_, feat_part = split_on_feat(artist, False)
return feat_part
class FtInTitlePlugin(plugins.BeetsPlugin): class FtInTitlePlugin(plugins.BeetsPlugin):
@ -153,8 +158,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"artist: {.artist} (Not changing due to keep_in_artist)", item "artist: {.artist} (Not changing due to keep_in_artist)", item
) )
else: else:
self._log.info("artist: {0.artist} -> {0.albumartist}", item) track_artist, _ = split_on_feat(item.artist)
item.artist = item.albumartist self._log.info("artist: {0.artist} -> {1}", item, track_artist)
item.artist = track_artist
if item.artist_sort: if item.artist_sort:
# Just strip the featured artist from the sort name. # Just strip the featured artist from the sort name.
@ -187,7 +193,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
# Check whether there is a featured artist on this track and the # Check whether there is a featured artist on this track and the
# artist field does not exactly match the album artist field. In # artist field does not exactly match the album artist field. In
# that case, we attempt to move the featured artist to the title. # that case, we attempt to move the featured artist to the title.
if not albumartist or albumartist == artist: if albumartist and artist == albumartist:
return False return False
_, featured = split_on_feat(artist) _, featured = split_on_feat(artist)

View file

@ -461,6 +461,12 @@ class LastGenrePlugin(plugins.BeetsPlugin):
def commands(self): def commands(self):
lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres")
lastgenre_cmd.parser.add_option(
"-p",
"--pretend",
action="store_true",
help="show actions but do nothing",
)
lastgenre_cmd.parser.add_option( lastgenre_cmd.parser.add_option(
"-f", "-f",
"--force", "--force",
@ -521,17 +527,23 @@ class LastGenrePlugin(plugins.BeetsPlugin):
def lastgenre_func(lib, opts, args): def lastgenre_func(lib, opts, args):
write = ui.should_write() write = ui.should_write()
pretend = getattr(opts, "pretend", False)
self.config.set_args(opts) self.config.set_args(opts)
if opts.album: if opts.album:
# Fetch genres for whole albums # Fetch genres for whole albums
for album in lib.albums(args): for album in lib.albums(args):
album.genre, src = self._get_genre(album) album_genre, src = self._get_genre(album)
prefix = "Pretend: " if pretend else ""
self._log.info( self._log.info(
'genre for album "{0.album}" ({1}): {0.genre}', '{}genre for album "{.album}" ({}): {}',
prefix,
album, album,
src, src,
album_genre,
) )
if not pretend:
album.genre = album_genre
if "track" in self.sources: if "track" in self.sources:
album.store(inherit=False) album.store(inherit=False)
else: else:
@ -541,25 +553,38 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# If we're using track-level sources, also look up each # If we're using track-level sources, also look up each
# track on the album. # track on the album.
if "track" in self.sources: if "track" in self.sources:
item.genre, src = self._get_genre(item) item_genre, src = self._get_genre(item)
item.store()
self._log.info( self._log.info(
'genre for track "{0.title}" ({1}): {0.genre}', '{}genre for track "{.title}" ({}): {}',
prefix,
item, item,
src, src,
item_genre,
) )
if not pretend:
item.genre = item_genre
item.store()
if write: if write and not pretend:
item.try_write() item.try_write()
else: else:
# Just query singletons, i.e. items that are not part of # Just query singletons, i.e. items that are not part of
# an album # an album
for item in lib.items(args): for item in lib.items(args):
item.genre, src = self._get_genre(item) item_genre, src = self._get_genre(item)
item.store() prefix = "Pretend: " if pretend else ""
self._log.info( self._log.info(
"genre for track {0.title} ({1}): {0.genre}", item, src '{}genre for track "{0.title}" ({1}): {}',
prefix,
item,
src,
item_genre,
) )
if not pretend:
item.genre = item_genre
item.store()
if write and not pretend:
item.try_write()
lastgenre_cmd.func = lastgenre_func lastgenre_cmd.func = lastgenre_func
return [lastgenre_cmd] return [lastgenre_cmd]

View file

@ -42,10 +42,9 @@ from beets.autotag.distance import string_dist
from beets.util.config import sanitize_choices from beets.util.config import sanitize_choices
if TYPE_CHECKING: if TYPE_CHECKING:
from logging import Logger
from beets.importer import ImportTask from beets.importer import ImportTask
from beets.library import Item, Library from beets.library import Item, Library
from beets.logging import BeetsLogger as Logger
from ._typing import ( from ._typing import (
GeniusAPI, GeniusAPI,
@ -186,7 +185,7 @@ def slug(text: str) -> str:
class RequestHandler: class RequestHandler:
_log: beets.logging.Logger _log: Logger
def debug(self, message: str, *args) -> None: def debug(self, message: str, *args) -> None:
"""Log a debug message with the class name.""" """Log a debug message with the class name."""

View file

@ -130,9 +130,6 @@ class SpotifyPlugin(
"mode": "list", "mode": "list",
"tiebreak": "popularity", "tiebreak": "popularity",
"show_failures": False, "show_failures": False,
"artist_field": "albumartist",
"album_field": "album",
"track_field": "title",
"region_filter": None, "region_filter": None,
"regex": [], "regex": [],
"client_id": "4e414367a1d14c75a5c5129a627fcab8", "client_id": "4e414367a1d14c75a5c5129a627fcab8",
@ -563,13 +560,17 @@ class SpotifyPlugin(
regex["search"], regex["replace"], value regex["search"], regex["replace"], value
) )
# Custom values can be passed in the config (just in case) artist = item["artist"] or item["albumartist"]
artist = item[self.config["artist_field"].get()] album = item["album"]
album = item[self.config["album_field"].get()] query_string = item["title"]
query_string = item[self.config["track_field"].get()]
# Query the Web API for each track, look for the items' JSON data # Query the Web API for each track, look for the items' JSON data
query_filters: SearchFilter = {"artist": artist, "album": album} query_filters: SearchFilter = {}
if artist:
query_filters["artist"] = artist
if album:
query_filters["album"] = album
response_data_tracks = self._search_api( response_data_tracks = self._search_api(
query_type="track", query_type="track",
query_string=query_string, query_string=query_string,

View file

@ -9,10 +9,33 @@ Unreleased
New features: New features:
- :doc:`plugins/lastgenre`: Add a ``--pretend`` option to preview genre changes
without storing or writing them.
- :doc:`plugins/convert`: Add a config option to disable writing metadata to
converted files.
- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle
stripping discogs numeric disambiguation on artist and label fields.
- :doc:`plugins/discogs` Added support for featured artists.
Bug fixes: Bug fixes:
- :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor - :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor
regexps, allow for more cases, add some logging), add tests. regexps, allow for more cases, add some logging), add tests.
- :doc:`plugins/spotify` Fixed an issue where track matching and lookups could
return incorrect or misleading results when using the Spotify plugin. The
problem occurred primarily when no album was provided or when the album field
was an empty string. :bug:`5189`
- :doc:`plugins/spotify` Removed old and undocumented config options
`artist_field`, `album_field` and `track` that were causing issues with track
matching. :bug:`5189`
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
artists but not labels. :bug:`5366`
- :doc:`plugins/discogs` Fixed issue with ignoring featured artists in the
extraartists field.
- :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find
matches due to query escaping (single vs double quotes).
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
For packagers: For packagers:
@ -24,6 +47,17 @@ Other changes:
match :bug:`6020` match :bug:`6020`
- :doc:`guides/tagger`: Section on no matching release found, related to - :doc:`guides/tagger`: Section on no matching release found, related to
possibly disabled musicbrainz plugin :bug:`6020` possibly disabled musicbrainz plugin :bug:`6020`
- Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as
it is not used in the core beets codebase. It can now be found in
``beetsplug._utils``.
- :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific
disambiguation stripping.
For developers and plugin authors:
- Typing improvements in ``beets/logging.py``: ``getLogger`` now returns
``BeetsLogger`` when called with a name, or ``RootLogger`` when called without
a name.
2.4.0 (September 13, 2025) 2.4.0 (September 13, 2025)
-------------------------- --------------------------
@ -150,6 +184,9 @@ Other changes:
Autogenerated API references are now located in the ``docs/api`` subdirectory. Autogenerated API references are now located in the ``docs/api`` subdirectory.
- :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each - :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each
case is shown on separate lines. case is shown on separate lines.
- :doc:`/plugins/ftintitle`: Process items whose albumartist is not contained in
the artist field, including compilations using Various Artists as an
albumartist and album tracks by guest artists featuring a third artist.
- Refactored library.py file by splitting it into multiple modules within the - Refactored library.py file by splitting it into multiple modules within the
beets/library directory. beets/library directory.
- Added a test to check that all plugins can be imported without errors. - Added a test to check that all plugins can be imported without errors.

View file

@ -97,6 +97,8 @@ The available options are:
- **embed**: Embed album art in converted items. Default: ``yes``. - **embed**: Embed album art in converted items. Default: ``yes``.
- **id3v23**: Can be used to override the global ``id3v23`` option. Default: - **id3v23**: Can be used to override the global ``id3v23`` option. Default:
``inherit``. ``inherit``.
- **write_metadata**: Can be used to disable writing metadata to converted
files. Default: ``true``.
- **max_bitrate**: By default, the plugin does not transcode files that are - **max_bitrate**: By default, the plugin does not transcode files that are
already in the destination format. This option instead also transcodes files already in the destination format. This option instead also transcodes files
with high bitrates, even if they are already in the same format as the output. with high bitrates, even if they are already in the same format as the output.

View file

@ -109,6 +109,9 @@ Other configurations available under ``discogs:`` are:
- **search_limit**: The maximum number of results to return from Discogs. This - **search_limit**: The maximum number of results to return from Discogs. This
is useful if you want to limit the number of results returned to speed up is useful if you want to limit the number of results returned to speed up
searches. Default: ``5`` searches. Default: ``5``
- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct
artists and labels with the same name. If you'd like to use the discogs
disambiguation in your tags, you can disable it. Default: ``True``
.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings

View file

@ -124,7 +124,7 @@ tags** and will only **fetch new genres for empty tags**. When ``force`` is
``yes`` the setting of the ``whitelist`` option (as documented in Usage_) ``yes`` the setting of the ``whitelist`` option (as documented in Usage_)
applies to any existing or newly fetched genres. applies to any existing or newly fetched genres.
The follwing configurations are possible: The following configurations are possible:
**Setup 1** (default) **Setup 1** (default)
@ -213,5 +213,9 @@ fetch genres for albums or items matching a certain query.
By default, ``beet lastgenre`` matches albums. To match individual tracks or By default, ``beet lastgenre`` matches albums. To match individual tracks or
singletons, use the ``-A`` switch: ``beet lastgenre -A [QUERY]``. singletons, use the ``-A`` switch: ``beet lastgenre -A [QUERY]``.
To preview the changes that would be made without applying them, use the ``-p``
or ``--pretend`` flag. This shows which genres would be set but does not write
or store any changes.
To disable automatic genre fetching on import, set the ``auto`` config option to To disable automatic genre fetching on import, set the ``auto`` config option to
false. false.

View file

@ -39,21 +39,27 @@ Configuration
To configure the plugin, make a ``missing:`` section in your configuration file. To configure the plugin, make a ``missing:`` section in your configuration file.
The available options are: The available options are:
- **count**: Print a count of missing tracks per album, with ``format`` - **count**: Print a count of missing tracks per album, with the global
defaulting to ``$albumartist - $album: $missing``. Default: ``no``. ``format_album`` used for formatting. Default: ``no``.
- **format**: A specific format with which to print every track. This uses the
same template syntax as beets' :doc:`path formats </reference/pathformat>`.
The usage is inspired by, and therefore similar to, the :ref:`list <list-cmd>`
command. Default: :ref:`format_item`.
- **total**: Print a single count of missing tracks in all albums. Default: - **total**: Print a single count of missing tracks in all albums. Default:
``no``. ``no``.
Formatting
~~~~~~~~~~
- This plugin uses global formatting options from the main configuration; see
:ref:`format_item` and :ref:`format_album`:
- :ref:`format_item`: Used when listing missing tracks (default item format).
- :ref:`format_album`: Used when showing counts (``-c``) or missing albums
(``-a``).
Here's an example Here's an example
:: ::
format_album: $albumartist - $album
format_item: $artist - $album - $title
missing: missing:
format: $albumartist - $album - $title
count: no count: no
total: no total: no

View file

@ -374,6 +374,129 @@ class DGAlbumInfoTest(BeetsTestCase):
assert d.genre == "GENRE1, GENRE2" assert d.genre == "GENRE1, GENRE2"
assert d.style is None assert d.style is None
def test_strip_disambiguation(self):
"""Test removing disambiguation from all disambiguated fields."""
data = {
"id": 123,
"uri": "https://www.discogs.com/release/123456-something",
"tracklist": [
{
"title": "track",
"position": "A",
"type_": "track",
"duration": "5:44",
"artists": [
{"name": "TEST ARTIST (5)", "tracks": "", "id": 11146}
],
}
],
"artists": [
{"name": "ARTIST NAME (2)", "id": 321, "join": "&"},
{"name": "OTHER ARTIST (5)", "id": 321, "join": ""},
],
"title": "title",
"labels": [
{
"name": "LABEL NAME (5)",
"catno": "catalog number",
}
],
}
release = Bag(
data=data,
title=data["title"],
artists=[Bag(data=d) for d in data["artists"]],
)
d = DiscogsPlugin().get_album_info(release)
assert d.artist == "ARTIST NAME & OTHER ARTIST"
assert d.tracks[0].artist == "TEST ARTIST"
assert d.label == "LABEL NAME"
def test_strip_disambiguation_false(self):
"""Test disabling disambiguation removal from all disambiguated fields."""
config["discogs"]["strip_disambiguation"] = False
data = {
"id": 123,
"uri": "https://www.discogs.com/release/123456-something",
"tracklist": [
{
"title": "track",
"position": "A",
"type_": "track",
"duration": "5:44",
"artists": [
{"name": "TEST ARTIST (5)", "tracks": "", "id": 11146}
],
}
],
"artists": [
{"name": "ARTIST NAME (2)", "id": 321, "join": "&"},
{"name": "OTHER ARTIST (5)", "id": 321, "join": ""},
],
"title": "title",
"labels": [
{
"name": "LABEL NAME (5)",
"catno": "catalog number",
}
],
}
release = Bag(
data=data,
title=data["title"],
artists=[Bag(data=d) for d in data["artists"]],
)
d = DiscogsPlugin().get_album_info(release)
assert d.artist == "ARTIST NAME (2) & OTHER ARTIST (5)"
assert d.tracks[0].artist == "TEST ARTIST (5)"
assert d.label == "LABEL NAME (5)"
config["discogs"]["strip_disambiguation"] = True
@pytest.mark.parametrize(
"track, expected_artist",
[
(
{
"type_": "track",
"title": "track",
"position": "1",
"duration": "5:00",
"artists": [
{"name": "NEW ARTIST", "tracks": "", "id": 11146},
{"name": "VOCALIST", "tracks": "", "id": 344, "join": "&"},
],
"extraartists": [
{
"name": "SOLOIST",
"role": "Featuring",
},
{
"name": "PERFORMER (1)",
"role": "Other Role, Featuring",
},
{
"name": "RANDOM",
"role": "Written-By",
},
{
"name": "MUSICIAN",
"role": "Featuring [Uncredited]",
},
],
},
"NEW ARTIST, VOCALIST feat. SOLOIST, PERFORMER, MUSICIAN",
),
],
)
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
def test_parse_featured_artists(track, expected_artist):
"""Tests the plugins ability to parse a featured artist.
Initial check with one featured artist, two featured artists,
and three. Ignores artists that are not listed as featured."""
t = DiscogsPlugin().get_track_info(track, 1, 1, "ARTIST", 2)
assert t.artist == expected_artist
@pytest.mark.parametrize( @pytest.mark.parametrize(
"formats, expected_media, expected_albumtype", "formats, expected_media, expected_albumtype",

View file

@ -23,7 +23,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from mediafile import MediaFile from mediafile import MediaFile
from beets import art, config, logging, ui from beets import config, logging, ui
from beets.test import _common from beets.test import _common
from beets.test.helper import ( from beets.test.helper import (
BeetsTestCase, BeetsTestCase,
@ -33,6 +33,7 @@ from beets.test.helper import (
) )
from beets.util import bytestring_path, displayable_path, syspath from beets.util import bytestring_path, displayable_path, syspath
from beets.util.artresizer import ArtResizer from beets.util.artresizer import ArtResizer
from beetsplug._utils import art
from test.test_art_resize import DummyIMBackend from test.test_art_resize import DummyIMBackend
@ -283,7 +284,7 @@ class DummyArtResizer(ArtResizer):
@patch("beets.util.artresizer.subprocess") @patch("beets.util.artresizer.subprocess")
@patch("beets.art.extract") @patch("beetsplug._utils.art.extract")
class ArtSimilarityTest(unittest.TestCase): class ArtSimilarityTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.item = _common.item() self.item = _common.item()

View file

@ -14,8 +14,11 @@
"""Tests for the 'ftintitle' plugin.""" """Tests for the 'ftintitle' plugin."""
import unittest from typing import Dict, Generator, Optional, Tuple, Union
import pytest
from beets.library.models import Item
from beets.test.helper import PluginTestCase from beets.test.helper import PluginTestCase
from beetsplug import ftintitle from beetsplug import ftintitle
@ -23,169 +26,233 @@ from beetsplug import ftintitle
class FtInTitlePluginFunctional(PluginTestCase): class FtInTitlePluginFunctional(PluginTestCase):
plugin = "ftintitle" plugin = "ftintitle"
def _ft_add_item(self, path, artist, title, aartist):
return self.add_item( @pytest.fixture
def env() -> Generator[FtInTitlePluginFunctional, None, None]:
case = FtInTitlePluginFunctional(methodName="runTest")
case.setUp()
try:
yield case
finally:
case.tearDown()
def set_config(
env: FtInTitlePluginFunctional, cfg: Optional[Dict[str, Union[str, bool]]]
) -> None:
cfg = {} if cfg is None else cfg
defaults = {
"drop": False,
"auto": True,
"keep_in_artist": False,
}
env.config["ftintitle"].set(defaults)
env.config["ftintitle"].set(cfg)
def add_item(
env: FtInTitlePluginFunctional,
path: str,
artist: str,
title: str,
albumartist: Optional[str],
) -> Item:
return env.add_item(
path=path, path=path,
artist=artist, artist=artist,
artist_sort=artist, artist_sort=artist,
title=title, title=title,
albumartist=aartist, albumartist=albumartist,
) )
def _ft_set_config(
self, ftformat, drop=False, auto=True, keep_in_artist=False
):
self.config["ftintitle"]["format"] = ftformat
self.config["ftintitle"]["drop"] = drop
self.config["ftintitle"]["auto"] = auto
self.config["ftintitle"]["keep_in_artist"] = keep_in_artist
def test_functional_drop(self): @pytest.mark.parametrize(
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") "cfg, cmd_args, given, expected",
self.run_command("ftintitle", "-d") [
item.load() pytest.param(
assert item["artist"] == "Alice" None,
assert item["title"] == "Song 1" ("ftintitle",),
("Alice", "Song 1", "Alice"),
def test_functional_not_found(self): ("Alice", "Song 1"),
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George") id="no-featured-artist",
self.run_command("ftintitle", "-d") ),
item.load() pytest.param(
# item should be unchanged {"format": "feat {0}"},
assert item["artist"] == "Alice ft Bob" ("ftintitle",),
assert item["title"] == "Song 1" ("Alice ft. Bob", "Song 1", None),
("Alice", "Song 1 feat Bob"),
def test_functional_custom_format(self): id="no-albumartist-custom-format",
self._ft_set_config("feat. {}") ),
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") pytest.param(
self.run_command("ftintitle") None,
item.load() ("ftintitle",),
assert item["artist"] == "Alice" ("Alice", "Song 1", None),
assert item["title"] == "Song 1 feat. Bob" ("Alice", "Song 1"),
id="no-albumartist-no-feature",
self._ft_set_config("featuring {}") ),
item = self._ft_add_item("/", "Alice feat. Bob", "Song 1", "Alice") pytest.param(
self.run_command("ftintitle") {"format": "featuring {0}"},
item.load() ("ftintitle",),
assert item["artist"] == "Alice" ("Alice ft Bob", "Song 1", "George"),
assert item["title"] == "Song 1 featuring Bob" ("Alice", "Song 1 featuring Bob"),
id="guest-artist-custom-format",
self._ft_set_config("with {}") ),
item = self._ft_add_item("/", "Alice feat Bob", "Song 1", "Alice") pytest.param(
self.run_command("ftintitle") None,
item.load() ("ftintitle",),
assert item["artist"] == "Alice" ("Alice", "Song 1", "George"),
assert item["title"] == "Song 1 with Bob" ("Alice", "Song 1"),
id="guest-artist-no-feature",
def test_functional_keep_in_artist(self): ),
self._ft_set_config("feat. {}", keep_in_artist=True) # ---- drop (-d) variants ----
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") pytest.param(
self.run_command("ftintitle") None,
item.load() ("ftintitle", "-d"),
assert item["artist"] == "Alice ft Bob" ("Alice ft Bob", "Song 1", "Alice"),
assert item["title"] == "Song 1 feat. Bob" ("Alice", "Song 1"),
id="drop-self-ft",
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") ),
self.run_command("ftintitle", "-d") pytest.param(
item.load() None,
assert item["artist"] == "Alice ft Bob" ("ftintitle", "-d"),
assert item["title"] == "Song 1" ("Alice", "Song 1", "Alice"),
("Alice", "Song 1"),
id="drop-self-no-ft",
class FtInTitlePluginTest(unittest.TestCase): ),
def setUp(self): pytest.param(
"""Set up configuration""" None,
("ftintitle", "-d"),
("Alice ft Bob", "Song 1", "George"),
("Alice", "Song 1"),
id="drop-guest-ft",
),
pytest.param(
None,
("ftintitle", "-d"),
("Alice", "Song 1", "George"),
("Alice", "Song 1"),
id="drop-guest-no-ft",
),
# ---- custom format variants ----
pytest.param(
{"format": "feat. {}"},
("ftintitle",),
("Alice ft Bob", "Song 1", "Alice"),
("Alice", "Song 1 feat. Bob"),
id="custom-format-feat-dot",
),
pytest.param(
{"format": "featuring {}"},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice"),
("Alice", "Song 1 featuring Bob"),
id="custom-format-featuring",
),
pytest.param(
{"format": "with {}"},
("ftintitle",),
("Alice feat Bob", "Song 1", "Alice"),
("Alice", "Song 1 with Bob"),
id="custom-format-with",
),
# ---- keep_in_artist variants ----
pytest.param(
{"format": "feat. {}", "keep_in_artist": True},
("ftintitle",),
("Alice ft Bob", "Song 1", "Alice"),
("Alice ft Bob", "Song 1 feat. Bob"),
id="keep-in-artist-add-to-title",
),
pytest.param(
{"format": "feat. {}", "keep_in_artist": True},
("ftintitle", "-d"),
("Alice ft Bob", "Song 1", "Alice"),
("Alice ft Bob", "Song 1"),
id="keep-in-artist-drop-from-title",
),
],
)
def test_ftintitle_functional(
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool]]],
cmd_args: Tuple[str, ...],
given: Tuple[str, str, Optional[str]],
expected: Tuple[str, str],
) -> None:
set_config(env, cfg)
ftintitle.FtInTitlePlugin() ftintitle.FtInTitlePlugin()
def test_find_feat_part(self): artist, title, albumartist = given
test_cases = [ item = add_item(env, "/", artist, title, albumartist)
{
"artist": "Alice ft. Bob",
"album_artist": "Alice",
"feat_part": "Bob",
},
{
"artist": "Alice feat Bob",
"album_artist": "Alice",
"feat_part": "Bob",
},
{
"artist": "Alice featuring Bob",
"album_artist": "Alice",
"feat_part": "Bob",
},
{
"artist": "Alice & Bob",
"album_artist": "Alice",
"feat_part": "Bob",
},
{
"artist": "Alice and Bob",
"album_artist": "Alice",
"feat_part": "Bob",
},
{
"artist": "Alice With Bob",
"album_artist": "Alice",
"feat_part": "Bob",
},
{
"artist": "Alice defeat Bob",
"album_artist": "Alice",
"feat_part": None,
},
{
"artist": "Alice & Bob",
"album_artist": "Bob",
"feat_part": "Alice",
},
{
"artist": "Alice ft. Bob",
"album_artist": "Bob",
"feat_part": "Alice",
},
{
"artist": "Alice ft. Carol",
"album_artist": "Bob",
"feat_part": None,
},
]
for test_case in test_cases: env.run_command(*cmd_args)
feat_part = ftintitle.find_feat_part( item.load()
test_case["artist"], test_case["album_artist"]
expected_artist, expected_title = expected
assert item["artist"] == expected_artist
assert item["title"] == expected_title
@pytest.mark.parametrize(
"artist,albumartist,expected",
[
("Alice ft. Bob", "Alice", "Bob"),
("Alice feat Bob", "Alice", "Bob"),
("Alice featuring Bob", "Alice", "Bob"),
("Alice & Bob", "Alice", "Bob"),
("Alice and Bob", "Alice", "Bob"),
("Alice With Bob", "Alice", "Bob"),
("Alice defeat Bob", "Alice", None),
("Alice & Bob", "Bob", "Alice"),
("Alice ft. Bob", "Bob", "Alice"),
("Alice ft. Carol", "Bob", "Carol"),
],
) )
assert feat_part == test_case["feat_part"] def test_find_feat_part(
artist: str,
albumartist: str,
expected: Optional[str],
) -> None:
assert ftintitle.find_feat_part(artist, albumartist) == expected
def test_split_on_feat(self):
parts = ftintitle.split_on_feat("Alice ft. Bob")
assert parts == ("Alice", "Bob")
parts = ftintitle.split_on_feat("Alice feat Bob")
assert parts == ("Alice", "Bob")
parts = ftintitle.split_on_feat("Alice feat. Bob")
assert parts == ("Alice", "Bob")
parts = ftintitle.split_on_feat("Alice featuring Bob")
assert parts == ("Alice", "Bob")
parts = ftintitle.split_on_feat("Alice & Bob")
assert parts == ("Alice", "Bob")
parts = ftintitle.split_on_feat("Alice and Bob")
assert parts == ("Alice", "Bob")
parts = ftintitle.split_on_feat("Alice With Bob")
assert parts == ("Alice", "Bob")
parts = ftintitle.split_on_feat("Alice defeat Bob")
assert parts == ("Alice defeat Bob", None)
def test_contains_feat(self): @pytest.mark.parametrize(
assert ftintitle.contains_feat("Alice ft. Bob") "given,expected",
assert ftintitle.contains_feat("Alice feat. Bob") [
assert ftintitle.contains_feat("Alice feat Bob") ("Alice ft. Bob", ("Alice", "Bob")),
assert ftintitle.contains_feat("Alice featuring Bob") ("Alice feat Bob", ("Alice", "Bob")),
assert ftintitle.contains_feat("Alice (ft. Bob)") ("Alice feat. Bob", ("Alice", "Bob")),
assert ftintitle.contains_feat("Alice (feat. Bob)") ("Alice featuring Bob", ("Alice", "Bob")),
assert ftintitle.contains_feat("Alice [ft. Bob]") ("Alice & Bob", ("Alice", "Bob")),
assert ftintitle.contains_feat("Alice [feat. Bob]") ("Alice and Bob", ("Alice", "Bob")),
assert not ftintitle.contains_feat("Alice defeat Bob") ("Alice With Bob", ("Alice", "Bob")),
assert not ftintitle.contains_feat("Aliceft.Bob") ("Alice defeat Bob", ("Alice defeat Bob", None)),
assert not ftintitle.contains_feat("Alice (defeat Bob)") ],
assert not ftintitle.contains_feat("Live and Let Go") )
assert not ftintitle.contains_feat("Come With Me") def test_split_on_feat(
given: str,
expected: Tuple[str, Optional[str]],
) -> None:
assert ftintitle.split_on_feat(given) == expected
@pytest.mark.parametrize(
"given,expected",
[
("Alice ft. Bob", True),
("Alice feat. Bob", True),
("Alice feat Bob", True),
("Alice featuring Bob", True),
("Alice (ft. Bob)", True),
("Alice (feat. Bob)", True),
("Alice [ft. Bob]", True),
("Alice [feat. Bob]", True),
("Alice defeat Bob", False),
("Aliceft.Bob", False),
("Alice (defeat Bob)", False),
("Live and Let Go", False),
("Come With Me", False),
],
)
def test_contains_feat(given: str, expected: bool) -> None:
assert ftintitle.contains_feat(given) is expected

View file

@ -14,7 +14,7 @@
"""Tests for the 'lastgenre' plugin.""" """Tests for the 'lastgenre' plugin."""
from unittest.mock import Mock from unittest.mock import Mock, patch
import pytest import pytest
@ -131,6 +131,43 @@ class LastGenrePluginTest(BeetsTestCase):
"math rock", "math rock",
] ]
def test_pretend_option_skips_library_updates(self):
item = self.create_item(
album="Pretend Album",
albumartist="Pretend Artist",
artist="Pretend Artist",
title="Pretend Track",
genre="Original Genre",
)
album = self.lib.add_album([item])
command = self.plugin.commands()[0]
opts, args = command.parser.parse_args(["--pretend"])
with patch.object(lastgenre.ui, "should_write", return_value=True):
with patch.object(
self.plugin,
"_get_genre",
return_value=("Mock Genre", "mock stage"),
) as mock_get_genre:
with patch.object(self.plugin._log, "info") as log_info:
# Mock try_write to verify it's never called in pretend mode
with patch.object(item, "try_write") as mock_try_write:
command.func(self.lib, opts, args)
mock_get_genre.assert_called_once()
assert any(
call.args[1] == "Pretend: " for call in log_info.call_args_list
)
# Verify that try_write was never called (file operations skipped)
mock_try_write.assert_not_called()
stored_album = self.lib.get_album(album.id)
assert stored_album.genre == "Original Genre"
assert stored_album.items()[0].genre == "Original Genre"
def test_no_duplicate(self): def test_no_duplicate(self):
"""Remove duplicated genres.""" """Remove duplicated genres."""
self._setup_config(count=99) self._setup_config(count=99)

View file

@ -82,8 +82,8 @@ class SpotifyPluginTest(PluginTestCase):
params = _params(responses.calls[0].request.url) params = _params(responses.calls[0].request.url)
query = params["q"][0] query = params["q"][0]
assert "duifhjslkef" in query assert "duifhjslkef" in query
assert 'artist:"ujydfsuihse"' in query assert "artist:'ujydfsuihse'" in query
assert 'album:"lkajsdflakjsd"' in query assert "album:'lkajsdflakjsd'" in query
assert params["type"] == ["track"] assert params["type"] == ["track"]
@responses.activate @responses.activate
@ -117,8 +117,8 @@ class SpotifyPluginTest(PluginTestCase):
params = _params(responses.calls[0].request.url) params = _params(responses.calls[0].request.url)
query = params["q"][0] query = params["q"][0]
assert "Happy" in query assert "Happy" in query
assert 'artist:"Pharrell Williams"' in query assert "artist:'Pharrell Williams'" in query
assert 'album:"Despicable Me 2"' in query assert "album:'Despicable Me 2'" in query
assert params["type"] == ["track"] assert params["type"] == ["track"]
@responses.activate @responses.activate
@ -233,8 +233,8 @@ class SpotifyPluginTest(PluginTestCase):
params = _params(responses.calls[0].request.url) params = _params(responses.calls[0].request.url)
query = params["q"][0] query = params["q"][0]
assert item.title in query assert item.title in query
assert f'artist:"{item.albumartist}"' in query assert f"artist:'{item.albumartist}'" in query
assert f'album:"{item.album}"' in query assert f"album:'{item.album}'" in query
assert not query.isascii() assert not query.isascii()
# Is not found in the library if ascii encoding is enabled # Is not found in the library if ascii encoding is enabled

View file

@ -23,6 +23,7 @@ from tempfile import mkstemp
import pytest import pytest
from beets import dbcore from beets import dbcore
from beets.dbcore.db import DBCustomFunctionError
from beets.library import LibModel from beets.library import LibModel
from beets.test import _common from beets.test import _common
from beets.util import cached_classproperty from beets.util import cached_classproperty
@ -31,6 +32,13 @@ from beets.util import cached_classproperty
# have multiple models with different numbers of fields. # have multiple models with different numbers of fields.
@pytest.fixture
def db(model):
db = model(":memory:")
yield db
db._connection().close()
class SortFixture(dbcore.query.FieldSort): class SortFixture(dbcore.query.FieldSort):
pass pass
@ -81,7 +89,6 @@ class ModelFixture1(LibModel):
class DatabaseFixture1(dbcore.Database): class DatabaseFixture1(dbcore.Database):
_models = (ModelFixture1,) _models = (ModelFixture1,)
pass
class ModelFixture2(ModelFixture1): class ModelFixture2(ModelFixture1):
@ -94,7 +101,6 @@ class ModelFixture2(ModelFixture1):
class DatabaseFixture2(dbcore.Database): class DatabaseFixture2(dbcore.Database):
_models = (ModelFixture2,) _models = (ModelFixture2,)
pass
class ModelFixture3(ModelFixture1): class ModelFixture3(ModelFixture1):
@ -108,7 +114,6 @@ class ModelFixture3(ModelFixture1):
class DatabaseFixture3(dbcore.Database): class DatabaseFixture3(dbcore.Database):
_models = (ModelFixture3,) _models = (ModelFixture3,)
pass
class ModelFixture4(ModelFixture1): class ModelFixture4(ModelFixture1):
@ -123,7 +128,6 @@ class ModelFixture4(ModelFixture1):
class DatabaseFixture4(dbcore.Database): class DatabaseFixture4(dbcore.Database):
_models = (ModelFixture4,) _models = (ModelFixture4,)
pass
class AnotherModelFixture(ModelFixture1): class AnotherModelFixture(ModelFixture1):
@ -145,12 +149,10 @@ class ModelFixture5(ModelFixture1):
class DatabaseFixture5(dbcore.Database): class DatabaseFixture5(dbcore.Database):
_models = (ModelFixture5,) _models = (ModelFixture5,)
pass
class DatabaseFixtureTwoModels(dbcore.Database): class DatabaseFixtureTwoModels(dbcore.Database):
_models = (ModelFixture2, AnotherModelFixture) _models = (ModelFixture2, AnotherModelFixture)
pass
class ModelFixtureWithGetters(dbcore.Model): class ModelFixtureWithGetters(dbcore.Model):
@ -784,3 +786,25 @@ class ResultsIteratorTest(unittest.TestCase):
self.db._fetch(ModelFixture1, dbcore.query.FalseQuery()).get() self.db._fetch(ModelFixture1, dbcore.query.FalseQuery()).get()
is None is None
) )
class TestException:
@pytest.mark.parametrize("model", [DatabaseFixture1])
@pytest.mark.filterwarnings(
"ignore: .*plz_raise.*: pytest.PytestUnraisableExceptionWarning"
)
@pytest.mark.filterwarnings(
"error: .*: pytest.PytestUnraisableExceptionWarning"
)
def test_custom_function_error(self, db: DatabaseFixture1):
def plz_raise():
raise Exception("i haz raized")
db._connection().create_function("plz_raise", 0, plz_raise)
with db.transaction() as tx:
tx.mutate("insert into test (field_one) values (1)")
with pytest.raises(DBCustomFunctionError):
with db.transaction() as tx:
tx.query("select * from test where plz_raise()")

View file

@ -3,18 +3,21 @@
import logging as log import logging as log
import sys import sys
import threading import threading
import unittest from types import ModuleType
from io import StringIO from unittest.mock import patch
import pytest
import beets.logging as blog import beets.logging as blog
import beetsplug
from beets import plugins, ui from beets import plugins, ui
from beets.test import _common, helper from beets.test import _common, helper
from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin
class LoggingTest(unittest.TestCase): class TestStrFormatLogger:
def test_logging_management(self): """Tests for the custom str-formatting logger."""
def test_logger_creation(self):
l1 = log.getLogger("foo123") l1 = log.getLogger("foo123")
l2 = blog.getLogger("foo123") l2 = blog.getLogger("foo123")
assert l1 == l2 assert l1 == l2
@ -34,23 +37,37 @@ class LoggingTest(unittest.TestCase):
l6 = blog.getLogger() l6 = blog.getLogger()
assert l1 != l6 assert l1 != l6
def test_str_format_logging(self): @pytest.mark.parametrize(
logger = blog.getLogger("baz123") "level", [log.DEBUG, log.INFO, log.WARNING, log.ERROR]
stream = StringIO() )
handler = log.StreamHandler(stream) @pytest.mark.parametrize(
"msg, args, kwargs, expected",
[
("foo {} bar {}", ("oof", "baz"), {}, "foo oof bar baz"),
(
"foo {bar} baz {foo}",
(),
{"foo": "oof", "bar": "baz"},
"foo baz baz oof",
),
("no args", (), {}, "no args"),
("foo {} bar {baz}", ("oof",), {"baz": "baz"}, "foo oof bar baz"),
],
)
def test_str_format_logging(
self, level, msg, args, kwargs, expected, caplog
):
logger = blog.getLogger("test_logger")
logger.setLevel(level)
logger.addHandler(handler) with caplog.at_level(level, logger="test_logger"):
logger.propagate = False logger.log(level, msg, *args, **kwargs)
logger.warning("foo {} {bar}", "oof", bar="baz") assert caplog.records, "No log records were captured"
handler.flush() assert str(caplog.records[0].msg) == expected
assert stream.getvalue(), "foo oof baz"
class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase): class DummyModule(ModuleType):
plugin = "dummy"
class DummyModule:
class DummyPlugin(plugins.BeetsPlugin): class DummyPlugin(plugins.BeetsPlugin):
def __init__(self): def __init__(self):
plugins.BeetsPlugin.__init__(self, "dummy") plugins.BeetsPlugin.__init__(self, "dummy")
@ -73,10 +90,23 @@ class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase):
def listener(self): def listener(self):
self.log_all("listener") self.log_all("listener")
def setUp(self): def __init__(self, *_, **__):
sys.modules["beetsplug.dummy"] = self.DummyModule module_name = "beetsplug.dummy"
beetsplug.dummy = self.DummyModule super().__init__(module_name)
super().setUp() self.DummyPlugin.__module__ = module_name
self.DummyPlugin = self.DummyPlugin
class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase):
plugin = "dummy"
@classmethod
def setUpClass(cls):
patcher = patch.dict(sys.modules, {"beetsplug.dummy": DummyModule()})
patcher.start()
cls.addClassCleanup(patcher.stop)
super().setUpClass()
def test_command_level0(self): def test_command_level0(self):
self.config["verbose"] = 0 self.config["verbose"] = 0