mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
Merge branch 'master' into ffnp
This commit is contained in:
commit
5f9800ec43
28 changed files with 786 additions and 320 deletions
|
|
@ -71,3 +71,5 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2
|
|||
07549ed896d9649562d40b75cd30702e6fa6e975
|
||||
# Moved plugin docs Further Reading chapter
|
||||
33f1a5d0bef8ca08be79ee7a0d02a018d502680d
|
||||
# Moved art.py utility module from beets into beetsplug
|
||||
28aee0fde463f1e18dfdba1994e2bdb80833722f
|
||||
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
|
@ -1,2 +1,5 @@
|
|||
# assign the entire repo to the maintainers team
|
||||
* @beetbox/maintainers
|
||||
|
||||
# Specific ownerships:
|
||||
/beets/metadata_plugins.py @semohr
|
||||
|
|
@ -17,15 +17,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC
|
||||
from collections import defaultdict
|
||||
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_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]):
|
||||
"""A `dict`-like formatted view of a model.
|
||||
|
||||
|
|
@ -947,6 +959,12 @@ class Transaction:
|
|||
self._mutated = False
|
||||
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(
|
||||
self, statement: str, subvals: Sequence[SQLiteType] = ()
|
||||
) -> list[sqlite3.Row]:
|
||||
|
|
@ -1007,6 +1025,13 @@ class Database:
|
|||
"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.timeout = timeout
|
||||
|
||||
|
|
@ -1102,9 +1127,16 @@ class Database:
|
|||
|
||||
return bytestring
|
||||
|
||||
conn.create_function("regexp", 2, regexp)
|
||||
conn.create_function("unidecode", 1, unidecode)
|
||||
conn.create_function("bytelower", 1, bytelower)
|
||||
create_function = conn.create_function
|
||||
if sys.version_info >= (3, 8) and sqlite_version_info >= (3, 8, 3):
|
||||
# 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):
|
||||
"""Close the all connections to the underlying SQLite database
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ use {}-style formatting and can interpolate keywords arguments to the logging
|
|||
calls (`debug`, `info`, etc).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from copy import copy
|
||||
from logging import (
|
||||
|
|
@ -32,8 +34,10 @@ from logging import (
|
|||
Handler,
|
||||
Logger,
|
||||
NullHandler,
|
||||
RootLogger,
|
||||
StreamHandler,
|
||||
)
|
||||
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
|
||||
|
||||
__all__ = [
|
||||
"DEBUG",
|
||||
|
|
@ -49,8 +53,20 @@ __all__ = [
|
|||
"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.
|
||||
|
||||
This is particularly relevant for bytestring paths. Much of our code
|
||||
|
|
@ -83,40 +99,45 @@ class StrFormatLogger(Logger):
|
|||
"""
|
||||
|
||||
class _LogMessage:
|
||||
def __init__(self, msg, args, kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
msg: str,
|
||||
args: _ArgsType,
|
||||
kwargs: dict[str, Any],
|
||||
):
|
||||
self.msg = msg
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __str__(self):
|
||||
args = [logsafe(a) for a in self.args]
|
||||
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
|
||||
args = [_logsafe(a) for a in self.args]
|
||||
kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()}
|
||||
return self.msg.format(*args, **kwargs)
|
||||
|
||||
def _log(
|
||||
self,
|
||||
level,
|
||||
msg,
|
||||
args,
|
||||
exc_info=None,
|
||||
extra=None,
|
||||
stack_info=False,
|
||||
level: int,
|
||||
msg: object,
|
||||
args: _ArgsType,
|
||||
exc_info: _ExcInfoType = None,
|
||||
extra: Mapping[str, Any] | None = None,
|
||||
stack_info: bool = False,
|
||||
stacklevel: int = 1,
|
||||
**kwargs,
|
||||
):
|
||||
"""Log msg.format(*args, **kwargs)"""
|
||||
m = self._LogMessage(msg, args, kwargs)
|
||||
|
||||
stacklevel = kwargs.pop("stacklevel", 1)
|
||||
stacklevel = {"stacklevel": stacklevel}
|
||||
if isinstance(msg, str):
|
||||
msg = self._LogMessage(msg, args, kwargs)
|
||||
|
||||
return super()._log(
|
||||
level,
|
||||
m,
|
||||
msg,
|
||||
(),
|
||||
exc_info=exc_info,
|
||||
extra=extra,
|
||||
stack_info=stack_info,
|
||||
**stacklevel,
|
||||
stacklevel=stacklevel,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -156,9 +177,12 @@ my_manager = copy(Logger.manager)
|
|||
my_manager.loggerClass = BeetsLogger
|
||||
|
||||
|
||||
# Override the `getLogger` to use our machinery.
|
||||
def getLogger(name=None): # noqa
|
||||
@overload
|
||||
def getLogger(name: str) -> BeetsLogger: ...
|
||||
@overload
|
||||
def getLogger(name: None = ...) -> RootLogger: ...
|
||||
def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802
|
||||
if name:
|
||||
return my_manager.getLogger(name)
|
||||
return my_manager.getLogger(name) # type: ignore[return-value]
|
||||
else:
|
||||
return Logger.root
|
||||
|
|
|
|||
|
|
@ -271,10 +271,9 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
|
|||
"""Returns an artist string (all artists) and an artist_id (the main
|
||||
artist) for a list of artist object dicts.
|
||||
|
||||
For each artist, this function moves articles (such as 'a', 'an',
|
||||
and 'the') to the front and strips trailing disambiguation numbers. It
|
||||
returns a tuple containing the comma-separated string of all
|
||||
normalized artists and the ``id`` of the main/first artist.
|
||||
For each artist, this function moves articles (such as 'a', 'an', and 'the')
|
||||
to the front. It returns a tuple containing the comma-separated string
|
||||
of all normalized artists and the ``id`` of the main/first artist.
|
||||
Alternatively a keyword can be used to combine artists together into a
|
||||
single string by passing the join_key argument.
|
||||
|
||||
|
|
@ -298,8 +297,6 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
|
|||
if not artist_id:
|
||||
artist_id = artist[id_key]
|
||||
name = artist[name_key]
|
||||
# Strip disambiguation number.
|
||||
name = re.sub(r" \(\d+\)$", "", name)
|
||||
# Move articles to the front.
|
||||
name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I)
|
||||
# Use a join keyword if requested and available.
|
||||
|
|
@ -371,7 +368,9 @@ class SearchApiMetadataSourcePlugin(
|
|||
album: str,
|
||||
va_likely: bool,
|
||||
) -> Iterable[AlbumInfo]:
|
||||
query_filters: SearchFilter = {"album": album}
|
||||
query_filters: SearchFilter = {}
|
||||
if album:
|
||||
query_filters["album"] = album
|
||||
if not va_likely:
|
||||
query_filters["artist"] = artist
|
||||
|
||||
|
|
@ -413,7 +412,7 @@ class SearchApiMetadataSourcePlugin(
|
|||
: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))
|
||||
|
||||
if self.config["search_query_ascii"].get():
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import re
|
|||
import sys
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from types import GenericAlias
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
|
||||
|
|
@ -365,11 +366,11 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
|
|||
"""
|
||||
try:
|
||||
try:
|
||||
namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None)
|
||||
namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}")
|
||||
except Exception as exc:
|
||||
raise PluginImportError(name) from exc
|
||||
|
||||
for obj in getattr(namespace, name).__dict__.values():
|
||||
for obj in namespace.__dict__.values():
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and not isinstance(
|
||||
|
|
@ -378,6 +379,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
|
|||
and issubclass(obj, BeetsPlugin)
|
||||
and obj != BeetsPlugin
|
||||
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()
|
||||
|
||||
|
|
|
|||
0
beetsplug/_utils/__init__.py
Normal file
0
beetsplug/_utils/__init__.py
Normal file
|
|
@ -25,12 +25,13 @@ from string import Template
|
|||
import mediafile
|
||||
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.plugins import BeetsPlugin
|
||||
from beets.util import par_map
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from beets.util.m3u import M3UFile
|
||||
from beetsplug._utils import art
|
||||
|
||||
_fs_lock = threading.Lock()
|
||||
_temp_files = [] # Keep track of temporary transcoded files for deletion.
|
||||
|
|
@ -121,6 +122,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
"threads": os.cpu_count(),
|
||||
"format": "mp3",
|
||||
"id3v23": "inherit",
|
||||
"write_metadata": True,
|
||||
"formats": {
|
||||
"aac": {
|
||||
"command": (
|
||||
|
|
@ -445,7 +447,8 @@ class ConvertPlugin(BeetsPlugin):
|
|||
if id3v23 == "inherit":
|
||||
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)
|
||||
|
||||
if keep_new:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ TRACK_INDEX_RE = re.compile(
|
|||
re.VERBOSE,
|
||||
)
|
||||
|
||||
DISAMBIGUATION_RE = re.compile(r" \(\d+\)")
|
||||
|
||||
|
||||
class ReleaseFormat(TypedDict):
|
||||
name: str
|
||||
|
|
@ -96,6 +98,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
"separator": ", ",
|
||||
"index_tracks": False,
|
||||
"append_style_genre": False,
|
||||
"strip_disambiguation": True,
|
||||
}
|
||||
)
|
||||
self.config["apikey"].redact = True
|
||||
|
|
@ -336,7 +339,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
# convenient `.tracklist` property, which will strip out useful artist
|
||||
# information and leave us with skeleton `Artist` objects that will
|
||||
# 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.
|
||||
va = result.data["artists"][0].get("name", "").lower() == "various"
|
||||
|
|
@ -362,15 +365,20 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
|
||||
label = catalogno = labelid = None
|
||||
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")
|
||||
labelid = result.data["labels"][0].get("id")
|
||||
|
||||
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:
|
||||
artist = config["va_name"].as_str()
|
||||
else:
|
||||
artist = self.strip_disambiguation(artist)
|
||||
if catalogno == "none":
|
||||
catalogno = None
|
||||
# Explicitly set the `media` for the tracks, since it is expected by
|
||||
|
|
@ -378,10 +386,6 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
for track in tracks:
|
||||
track.media = media
|
||||
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
|
||||
# in #2336.
|
||||
track.track_id = f"{album_id}-{track.track_alt}"
|
||||
|
|
@ -438,7 +442,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
else:
|
||||
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."""
|
||||
try:
|
||||
clean_tracklist = self.coalesce_tracks(tracklist)
|
||||
|
|
@ -463,7 +467,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
# divisions.
|
||||
divisions += 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"]
|
||||
tracks.append(track_info)
|
||||
else:
|
||||
|
|
@ -622,7 +628,17 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
|
||||
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."""
|
||||
title = track["title"]
|
||||
if self.config["index_tracks"]:
|
||||
|
|
@ -634,7 +650,21 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
artist, artist_id = self.get_artist(
|
||||
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"])
|
||||
# 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(
|
||||
title=title,
|
||||
track_id=track_id,
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@ from mimetypes import guess_extension
|
|||
|
||||
import requests
|
||||
|
||||
from beets import art, config, ui
|
||||
from beets import config, ui
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import print_
|
||||
from beets.util import bytestring_path, displayable_path, normpath, syspath
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from beetsplug._utils import art
|
||||
|
||||
|
||||
def _confirm(objs, album):
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ from beets.util.config import sanitize_pairs
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from logging import Logger
|
||||
|
||||
from beets.importer import ImportSession, ImportTask
|
||||
from beets.library import Album, Library
|
||||
from beets.logging import BeetsLogger as Logger
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
|
|
|||
|
|
@ -26,14 +26,16 @@ if TYPE_CHECKING:
|
|||
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
|
||||
on the right-hand side of a string like "feat". Return the main
|
||||
artist, which is always a string, and the featuring artist, which
|
||||
may be a string or None if none is present.
|
||||
"""
|
||||
# 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))
|
||||
if len(parts) == 1:
|
||||
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
|
||||
return the results. Returns None if no featured artist found.
|
||||
"""
|
||||
# Look for the album artist in the artist field. If it's not
|
||||
# present, give up.
|
||||
# Handle a wider variety of extraction cases if the album artist is
|
||||
# contained within the track artist.
|
||||
if albumartist and albumartist in artist:
|
||||
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
|
||||
# album artist) is nonempty, then it probably contains the
|
||||
# featured artist.
|
||||
elif albumartist_split[1] != "":
|
||||
if albumartist_split[1] != "":
|
||||
# Extract the featured artist from the right-hand side.
|
||||
_, feat_part = split_on_feat(albumartist_split[1])
|
||||
return feat_part
|
||||
|
||||
# Otherwise, if there's nothing on the right-hand side, look for a
|
||||
# featuring artist on the left-hand side.
|
||||
# Otherwise, if there's nothing on the right-hand side,
|
||||
# look for a featuring artist on the left-hand side.
|
||||
else:
|
||||
lhs, rhs = split_on_feat(albumartist_split[0])
|
||||
lhs, _ = split_on_feat(albumartist_split[0])
|
||||
if 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):
|
||||
|
|
@ -153,8 +158,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
"artist: {.artist} (Not changing due to keep_in_artist)", item
|
||||
)
|
||||
else:
|
||||
self._log.info("artist: {0.artist} -> {0.albumartist}", item)
|
||||
item.artist = item.albumartist
|
||||
track_artist, _ = split_on_feat(item.artist)
|
||||
self._log.info("artist: {0.artist} -> {1}", item, track_artist)
|
||||
item.artist = track_artist
|
||||
|
||||
if item.artist_sort:
|
||||
# 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
|
||||
# artist field does not exactly match the album artist field. In
|
||||
# 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
|
||||
|
||||
_, featured = split_on_feat(artist)
|
||||
|
|
|
|||
|
|
@ -461,6 +461,12 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
|
||||
def commands(self):
|
||||
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(
|
||||
"-f",
|
||||
"--force",
|
||||
|
|
@ -521,17 +527,23 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
|
||||
def lastgenre_func(lib, opts, args):
|
||||
write = ui.should_write()
|
||||
pretend = getattr(opts, "pretend", False)
|
||||
self.config.set_args(opts)
|
||||
|
||||
if opts.album:
|
||||
# Fetch genres for whole albums
|
||||
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(
|
||||
'genre for album "{0.album}" ({1}): {0.genre}',
|
||||
'{}genre for album "{.album}" ({}): {}',
|
||||
prefix,
|
||||
album,
|
||||
src,
|
||||
album_genre,
|
||||
)
|
||||
if not pretend:
|
||||
album.genre = album_genre
|
||||
if "track" in self.sources:
|
||||
album.store(inherit=False)
|
||||
else:
|
||||
|
|
@ -541,25 +553,38 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
# If we're using track-level sources, also look up each
|
||||
# track on the album.
|
||||
if "track" in self.sources:
|
||||
item.genre, src = self._get_genre(item)
|
||||
item.store()
|
||||
item_genre, src = self._get_genre(item)
|
||||
self._log.info(
|
||||
'genre for track "{0.title}" ({1}): {0.genre}',
|
||||
'{}genre for track "{.title}" ({}): {}',
|
||||
prefix,
|
||||
item,
|
||||
src,
|
||||
item_genre,
|
||||
)
|
||||
if not pretend:
|
||||
item.genre = item_genre
|
||||
item.store()
|
||||
|
||||
if write:
|
||||
if write and not pretend:
|
||||
item.try_write()
|
||||
else:
|
||||
# Just query singletons, i.e. items that are not part of
|
||||
# an album
|
||||
for item in lib.items(args):
|
||||
item.genre, src = self._get_genre(item)
|
||||
item.store()
|
||||
item_genre, src = self._get_genre(item)
|
||||
prefix = "Pretend: " if pretend else ""
|
||||
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
|
||||
return [lastgenre_cmd]
|
||||
|
|
|
|||
|
|
@ -42,10 +42,9 @@ from beets.autotag.distance import string_dist
|
|||
from beets.util.config import sanitize_choices
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from logging import Logger
|
||||
|
||||
from beets.importer import ImportTask
|
||||
from beets.library import Item, Library
|
||||
from beets.logging import BeetsLogger as Logger
|
||||
|
||||
from ._typing import (
|
||||
GeniusAPI,
|
||||
|
|
@ -186,7 +185,7 @@ def slug(text: str) -> str:
|
|||
|
||||
|
||||
class RequestHandler:
|
||||
_log: beets.logging.Logger
|
||||
_log: Logger
|
||||
|
||||
def debug(self, message: str, *args) -> None:
|
||||
"""Log a debug message with the class name."""
|
||||
|
|
|
|||
|
|
@ -130,9 +130,6 @@ class SpotifyPlugin(
|
|||
"mode": "list",
|
||||
"tiebreak": "popularity",
|
||||
"show_failures": False,
|
||||
"artist_field": "albumartist",
|
||||
"album_field": "album",
|
||||
"track_field": "title",
|
||||
"region_filter": None,
|
||||
"regex": [],
|
||||
"client_id": "4e414367a1d14c75a5c5129a627fcab8",
|
||||
|
|
@ -563,13 +560,17 @@ class SpotifyPlugin(
|
|||
regex["search"], regex["replace"], value
|
||||
)
|
||||
|
||||
# Custom values can be passed in the config (just in case)
|
||||
artist = item[self.config["artist_field"].get()]
|
||||
album = item[self.config["album_field"].get()]
|
||||
query_string = item[self.config["track_field"].get()]
|
||||
artist = item["artist"] or item["albumartist"]
|
||||
album = item["album"]
|
||||
query_string = item["title"]
|
||||
|
||||
# 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(
|
||||
query_type="track",
|
||||
query_string=query_string,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,33 @@ Unreleased
|
|||
|
||||
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:
|
||||
|
||||
- :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor
|
||||
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:
|
||||
|
||||
|
|
@ -24,6 +47,17 @@ Other changes:
|
|||
match :bug:`6020`
|
||||
- :doc:`guides/tagger`: Section on no matching release found, related to
|
||||
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)
|
||||
--------------------------
|
||||
|
|
@ -150,6 +184,9 @@ Other changes:
|
|||
Autogenerated API references are now located in the ``docs/api`` subdirectory.
|
||||
- :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each
|
||||
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
|
||||
beets/library directory.
|
||||
- Added a test to check that all plugins can be imported without errors.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ The available options are:
|
|||
- **embed**: Embed album art in converted items. Default: ``yes``.
|
||||
- **id3v23**: Can be used to override the global ``id3v23`` option. Default:
|
||||
``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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ Other configurations available under ``discogs:`` are:
|
|||
- **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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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_)
|
||||
applies to any existing or newly fetched genres.
|
||||
|
||||
The follwing configurations are possible:
|
||||
The following configurations are possible:
|
||||
|
||||
**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
|
||||
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
|
||||
false.
|
||||
|
|
|
|||
|
|
@ -39,21 +39,27 @@ Configuration
|
|||
To configure the plugin, make a ``missing:`` section in your configuration file.
|
||||
The available options are:
|
||||
|
||||
- **count**: Print a count of missing tracks per album, with ``format``
|
||||
defaulting to ``$albumartist - $album: $missing``. 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`.
|
||||
- **count**: Print a count of missing tracks per album, with the global
|
||||
``format_album`` used for formatting. Default: ``no``.
|
||||
- **total**: Print a single count of missing tracks in all albums. Default:
|
||||
``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
|
||||
|
||||
::
|
||||
|
||||
format_album: $albumartist - $album
|
||||
format_item: $artist - $album - $title
|
||||
missing:
|
||||
format: $albumartist - $album - $title
|
||||
count: no
|
||||
total: no
|
||||
|
||||
|
|
|
|||
|
|
@ -374,6 +374,129 @@ class DGAlbumInfoTest(BeetsTestCase):
|
|||
assert d.genre == "GENRE1, GENRE2"
|
||||
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(
|
||||
"formats, expected_media, expected_albumtype",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from unittest.mock import MagicMock, patch
|
|||
import pytest
|
||||
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.helper import (
|
||||
BeetsTestCase,
|
||||
|
|
@ -33,6 +33,7 @@ from beets.test.helper import (
|
|||
)
|
||||
from beets.util import bytestring_path, displayable_path, syspath
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from beetsplug._utils import art
|
||||
from test.test_art_resize import DummyIMBackend
|
||||
|
||||
|
||||
|
|
@ -283,7 +284,7 @@ class DummyArtResizer(ArtResizer):
|
|||
|
||||
|
||||
@patch("beets.util.artresizer.subprocess")
|
||||
@patch("beets.art.extract")
|
||||
@patch("beetsplug._utils.art.extract")
|
||||
class ArtSimilarityTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.item = _common.item()
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@
|
|||
|
||||
"""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 beetsplug import ftintitle
|
||||
|
||||
|
|
@ -23,169 +26,233 @@ from beetsplug import ftintitle
|
|||
class FtInTitlePluginFunctional(PluginTestCase):
|
||||
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,
|
||||
artist=artist,
|
||||
artist_sort=artist,
|
||||
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):
|
||||
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice")
|
||||
self.run_command("ftintitle", "-d")
|
||||
item.load()
|
||||
assert item["artist"] == "Alice"
|
||||
assert item["title"] == "Song 1"
|
||||
|
||||
def test_functional_not_found(self):
|
||||
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George")
|
||||
self.run_command("ftintitle", "-d")
|
||||
item.load()
|
||||
# item should be unchanged
|
||||
assert item["artist"] == "Alice ft Bob"
|
||||
assert item["title"] == "Song 1"
|
||||
|
||||
def test_functional_custom_format(self):
|
||||
self._ft_set_config("feat. {}")
|
||||
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice")
|
||||
self.run_command("ftintitle")
|
||||
item.load()
|
||||
assert item["artist"] == "Alice"
|
||||
assert item["title"] == "Song 1 feat. Bob"
|
||||
|
||||
self._ft_set_config("featuring {}")
|
||||
item = self._ft_add_item("/", "Alice feat. Bob", "Song 1", "Alice")
|
||||
self.run_command("ftintitle")
|
||||
item.load()
|
||||
assert item["artist"] == "Alice"
|
||||
assert item["title"] == "Song 1 featuring Bob"
|
||||
|
||||
self._ft_set_config("with {}")
|
||||
item = self._ft_add_item("/", "Alice feat Bob", "Song 1", "Alice")
|
||||
self.run_command("ftintitle")
|
||||
item.load()
|
||||
assert item["artist"] == "Alice"
|
||||
assert item["title"] == "Song 1 with Bob"
|
||||
|
||||
def test_functional_keep_in_artist(self):
|
||||
self._ft_set_config("feat. {}", keep_in_artist=True)
|
||||
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice")
|
||||
self.run_command("ftintitle")
|
||||
item.load()
|
||||
assert item["artist"] == "Alice ft Bob"
|
||||
assert item["title"] == "Song 1 feat. Bob"
|
||||
|
||||
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice")
|
||||
self.run_command("ftintitle", "-d")
|
||||
item.load()
|
||||
assert item["artist"] == "Alice ft Bob"
|
||||
assert item["title"] == "Song 1"
|
||||
|
||||
|
||||
class FtInTitlePluginTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up configuration"""
|
||||
@pytest.mark.parametrize(
|
||||
"cfg, cmd_args, given, expected",
|
||||
[
|
||||
pytest.param(
|
||||
None,
|
||||
("ftintitle",),
|
||||
("Alice", "Song 1", "Alice"),
|
||||
("Alice", "Song 1"),
|
||||
id="no-featured-artist",
|
||||
),
|
||||
pytest.param(
|
||||
{"format": "feat {0}"},
|
||||
("ftintitle",),
|
||||
("Alice ft. Bob", "Song 1", None),
|
||||
("Alice", "Song 1 feat Bob"),
|
||||
id="no-albumartist-custom-format",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
("ftintitle",),
|
||||
("Alice", "Song 1", None),
|
||||
("Alice", "Song 1"),
|
||||
id="no-albumartist-no-feature",
|
||||
),
|
||||
pytest.param(
|
||||
{"format": "featuring {0}"},
|
||||
("ftintitle",),
|
||||
("Alice ft Bob", "Song 1", "George"),
|
||||
("Alice", "Song 1 featuring Bob"),
|
||||
id="guest-artist-custom-format",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
("ftintitle",),
|
||||
("Alice", "Song 1", "George"),
|
||||
("Alice", "Song 1"),
|
||||
id="guest-artist-no-feature",
|
||||
),
|
||||
# ---- drop (-d) variants ----
|
||||
pytest.param(
|
||||
None,
|
||||
("ftintitle", "-d"),
|
||||
("Alice ft Bob", "Song 1", "Alice"),
|
||||
("Alice", "Song 1"),
|
||||
id="drop-self-ft",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
("ftintitle", "-d"),
|
||||
("Alice", "Song 1", "Alice"),
|
||||
("Alice", "Song 1"),
|
||||
id="drop-self-no-ft",
|
||||
),
|
||||
pytest.param(
|
||||
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()
|
||||
|
||||
def test_find_feat_part(self):
|
||||
test_cases = [
|
||||
{
|
||||
"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,
|
||||
},
|
||||
]
|
||||
artist, title, albumartist = given
|
||||
item = add_item(env, "/", artist, title, albumartist)
|
||||
|
||||
for test_case in test_cases:
|
||||
feat_part = ftintitle.find_feat_part(
|
||||
test_case["artist"], test_case["album_artist"]
|
||||
env.run_command(*cmd_args)
|
||||
item.load()
|
||||
|
||||
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):
|
||||
assert ftintitle.contains_feat("Alice ft. Bob")
|
||||
assert ftintitle.contains_feat("Alice feat. Bob")
|
||||
assert ftintitle.contains_feat("Alice feat Bob")
|
||||
assert ftintitle.contains_feat("Alice featuring Bob")
|
||||
assert ftintitle.contains_feat("Alice (ft. Bob)")
|
||||
assert ftintitle.contains_feat("Alice (feat. Bob)")
|
||||
assert ftintitle.contains_feat("Alice [ft. Bob]")
|
||||
assert ftintitle.contains_feat("Alice [feat. Bob]")
|
||||
assert not ftintitle.contains_feat("Alice defeat Bob")
|
||||
assert not ftintitle.contains_feat("Aliceft.Bob")
|
||||
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")
|
||||
@pytest.mark.parametrize(
|
||||
"given,expected",
|
||||
[
|
||||
("Alice ft. Bob", ("Alice", "Bob")),
|
||||
("Alice feat 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 defeat Bob", None)),
|
||||
],
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
"""Tests for the 'lastgenre' plugin."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -131,6 +131,43 @@ class LastGenrePluginTest(BeetsTestCase):
|
|||
"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):
|
||||
"""Remove duplicated genres."""
|
||||
self._setup_config(count=99)
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ class SpotifyPluginTest(PluginTestCase):
|
|||
params = _params(responses.calls[0].request.url)
|
||||
query = params["q"][0]
|
||||
assert "duifhjslkef" in query
|
||||
assert 'artist:"ujydfsuihse"' in query
|
||||
assert 'album:"lkajsdflakjsd"' in query
|
||||
assert "artist:'ujydfsuihse'" in query
|
||||
assert "album:'lkajsdflakjsd'" in query
|
||||
assert params["type"] == ["track"]
|
||||
|
||||
@responses.activate
|
||||
|
|
@ -117,8 +117,8 @@ class SpotifyPluginTest(PluginTestCase):
|
|||
params = _params(responses.calls[0].request.url)
|
||||
query = params["q"][0]
|
||||
assert "Happy" in query
|
||||
assert 'artist:"Pharrell Williams"' in query
|
||||
assert 'album:"Despicable Me 2"' in query
|
||||
assert "artist:'Pharrell Williams'" in query
|
||||
assert "album:'Despicable Me 2'" in query
|
||||
assert params["type"] == ["track"]
|
||||
|
||||
@responses.activate
|
||||
|
|
@ -233,8 +233,8 @@ class SpotifyPluginTest(PluginTestCase):
|
|||
params = _params(responses.calls[0].request.url)
|
||||
query = params["q"][0]
|
||||
assert item.title in query
|
||||
assert f'artist:"{item.albumartist}"' in query
|
||||
assert f'album:"{item.album}"' in query
|
||||
assert f"artist:'{item.albumartist}'" in query
|
||||
assert f"album:'{item.album}'" in query
|
||||
assert not query.isascii()
|
||||
|
||||
# Is not found in the library if ascii encoding is enabled
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from tempfile import mkstemp
|
|||
import pytest
|
||||
|
||||
from beets import dbcore
|
||||
from beets.dbcore.db import DBCustomFunctionError
|
||||
from beets.library import LibModel
|
||||
from beets.test import _common
|
||||
from beets.util import cached_classproperty
|
||||
|
|
@ -31,6 +32,13 @@ from beets.util import cached_classproperty
|
|||
# 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):
|
||||
pass
|
||||
|
||||
|
|
@ -81,7 +89,6 @@ class ModelFixture1(LibModel):
|
|||
|
||||
class DatabaseFixture1(dbcore.Database):
|
||||
_models = (ModelFixture1,)
|
||||
pass
|
||||
|
||||
|
||||
class ModelFixture2(ModelFixture1):
|
||||
|
|
@ -94,7 +101,6 @@ class ModelFixture2(ModelFixture1):
|
|||
|
||||
class DatabaseFixture2(dbcore.Database):
|
||||
_models = (ModelFixture2,)
|
||||
pass
|
||||
|
||||
|
||||
class ModelFixture3(ModelFixture1):
|
||||
|
|
@ -108,7 +114,6 @@ class ModelFixture3(ModelFixture1):
|
|||
|
||||
class DatabaseFixture3(dbcore.Database):
|
||||
_models = (ModelFixture3,)
|
||||
pass
|
||||
|
||||
|
||||
class ModelFixture4(ModelFixture1):
|
||||
|
|
@ -123,7 +128,6 @@ class ModelFixture4(ModelFixture1):
|
|||
|
||||
class DatabaseFixture4(dbcore.Database):
|
||||
_models = (ModelFixture4,)
|
||||
pass
|
||||
|
||||
|
||||
class AnotherModelFixture(ModelFixture1):
|
||||
|
|
@ -145,12 +149,10 @@ class ModelFixture5(ModelFixture1):
|
|||
|
||||
class DatabaseFixture5(dbcore.Database):
|
||||
_models = (ModelFixture5,)
|
||||
pass
|
||||
|
||||
|
||||
class DatabaseFixtureTwoModels(dbcore.Database):
|
||||
_models = (ModelFixture2, AnotherModelFixture)
|
||||
pass
|
||||
|
||||
|
||||
class ModelFixtureWithGetters(dbcore.Model):
|
||||
|
|
@ -784,3 +786,25 @@ class ResultsIteratorTest(unittest.TestCase):
|
|||
self.db._fetch(ModelFixture1, dbcore.query.FalseQuery()).get()
|
||||
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()")
|
||||
|
|
|
|||
|
|
@ -3,18 +3,21 @@
|
|||
import logging as log
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from types import ModuleType
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import beets.logging as blog
|
||||
import beetsplug
|
||||
from beets import plugins, ui
|
||||
from beets.test import _common, helper
|
||||
from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin
|
||||
|
||||
|
||||
class LoggingTest(unittest.TestCase):
|
||||
def test_logging_management(self):
|
||||
class TestStrFormatLogger:
|
||||
"""Tests for the custom str-formatting logger."""
|
||||
|
||||
def test_logger_creation(self):
|
||||
l1 = log.getLogger("foo123")
|
||||
l2 = blog.getLogger("foo123")
|
||||
assert l1 == l2
|
||||
|
|
@ -34,23 +37,37 @@ class LoggingTest(unittest.TestCase):
|
|||
l6 = blog.getLogger()
|
||||
assert l1 != l6
|
||||
|
||||
def test_str_format_logging(self):
|
||||
logger = blog.getLogger("baz123")
|
||||
stream = StringIO()
|
||||
handler = log.StreamHandler(stream)
|
||||
@pytest.mark.parametrize(
|
||||
"level", [log.DEBUG, log.INFO, log.WARNING, log.ERROR]
|
||||
)
|
||||
@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)
|
||||
logger.propagate = False
|
||||
with caplog.at_level(level, logger="test_logger"):
|
||||
logger.log(level, msg, *args, **kwargs)
|
||||
|
||||
logger.warning("foo {} {bar}", "oof", bar="baz")
|
||||
handler.flush()
|
||||
assert stream.getvalue(), "foo oof baz"
|
||||
assert caplog.records, "No log records were captured"
|
||||
assert str(caplog.records[0].msg) == expected
|
||||
|
||||
|
||||
class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase):
|
||||
plugin = "dummy"
|
||||
|
||||
class DummyModule:
|
||||
class DummyModule(ModuleType):
|
||||
class DummyPlugin(plugins.BeetsPlugin):
|
||||
def __init__(self):
|
||||
plugins.BeetsPlugin.__init__(self, "dummy")
|
||||
|
|
@ -73,10 +90,23 @@ class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase):
|
|||
def listener(self):
|
||||
self.log_all("listener")
|
||||
|
||||
def setUp(self):
|
||||
sys.modules["beetsplug.dummy"] = self.DummyModule
|
||||
beetsplug.dummy = self.DummyModule
|
||||
super().setUp()
|
||||
def __init__(self, *_, **__):
|
||||
module_name = "beetsplug.dummy"
|
||||
super().__init__(module_name)
|
||||
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):
|
||||
self.config["verbose"] = 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue