Merge branch 'master' into fix-mpdstats-cli-host

This commit is contained in:
Šarūnas Nejus
2026-06-04 06:03:48 +01:00
49 changed files with 1220 additions and 1200 deletions

View File

@@ -95,6 +95,8 @@ fbfdfd54446fab6782ef0629da303f14f0a2ecdf
c490ac5810b70f3cf5fd8649669838e8fdb19f4d
# Importer restructure
9147577b2b19f43ca827e9650261a86fb0450cef
# Renamed all `action` occurrences with `Action`.
68acaa6470a04474a2aefe9b1de71ae7fa10b861
# Move functionality under MusicBrainz plugin
529aaac7dced71266c6d69866748a7d044ec20ff
# musicbrainz: reorder methods
@@ -183,3 +185,15 @@ d4fd36a03384b0f71144932833d9c633c1f9545f
0cd20798aa585f162433423f767c047b7474a70e
# Reformat using skip-magic-trailing-comma
adfb73b66dba0fc799880af176ef6f9ac0a7ee68
# Refactor test_library
6493d4dd018e08eb5d16d5381ebff47deea98bb1
# Import everything from parent beets.autotag
29580d2f00eb02f1ce77ebe3e1435804f7252ba1
# Add setup fixture to TestHelper
7bad226c33c2d48b5102cff213c67f16eeb09a7b
# Fix mbcollection tests
544ea3af5d85df187e080f407d3aaf84eac4ee63
# Fix ftintitle tests
ca8544d4bcab503e1e4c2cd6688655c8211aa7a1
# Fix autobpm tests
5804a21620098e8da78b1f4a5f85063033586632

View File

@@ -21,8 +21,18 @@ from importlib import import_module
# Parts of external interface.
from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports
from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch
from .match import Proposal, Recommendation, tag_album, tag_item
from .distance import Distance, distance, string_dist, track_distance
from .hooks import AlbumInfo, AttrDict, Info, TrackInfo, correct_list_fields
from .match import (
AlbumMatch,
Match,
Proposal,
Recommendation,
TrackMatch,
assign_items,
tag_album,
tag_item,
)
def __getattr__(name: str):
@@ -32,18 +42,25 @@ def __getattr__(name: str):
)
return import_module("beets.util").get_most_common_tags
return deprecate_imports(
__name__, {"Distance": "beets.autotag.distance"}, name
)
return deprecate_imports(__name__, {}, name)
__all__ = [
"AlbumInfo",
"AlbumMatch",
"AttrDict",
"Distance",
"Info",
"Match",
"Proposal",
"Recommendation",
"TrackInfo",
"TrackMatch",
"assign_items",
"correct_list_fields",
"distance",
"string_dist",
"tag_album",
"tag_item",
"track_distance",
]

View File

@@ -17,13 +17,12 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from functools import cached_property
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
from typing import Any, ClassVar, TypeVar
from typing_extensions import Self
from beets import config, logging, plugins
from beets import config, logging
from beets.util import cached_classproperty, unique_list
from beets.util.deprecation import (
ALBUM_LEGACY_TO_LIST_FIELD,
@@ -31,13 +30,6 @@ from beets.util.deprecation import (
deprecate_for_maintainers,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Album, Item
from .distance import Distance
V = TypeVar("V")
JSONDict = dict[str, Any]
@@ -542,161 +534,3 @@ class TrackInfo(Info):
| track.item_data
)
return merged
# Structures that compose all the information for a candidate match.
@dataclass
class Match:
"""Represent a chosen metadata candidate and its application behavior."""
disambig_fields_key: ClassVar[str]
distance: Distance
info: Info
def apply_metadata(self) -> None:
"""Apply this match's metadata to its target library objects."""
raise NotImplementedError
@cached_property
def type(self) -> str:
return self.info.type
@cached_property
def config_from_scratch(self) -> bool:
return bool(config["import"]["from_scratch"])
def from_scratch(self, override: bool | None) -> bool:
if override is not None:
return override
return self.config_from_scratch
@property
def disambig_fields(self) -> Sequence[str]:
"""Return configured disambiguation fields that exist on this match."""
chosen_fields = config["match"][self.disambig_fields_key].as_str_seq()
valid_fields = [f for f in chosen_fields if f in self.info]
if missing_fields := set(chosen_fields) - set(valid_fields):
log.warning(
"Disambiguation string keys {} do not exist.", missing_fields
)
return valid_fields
@property
def base_disambig_data(self) -> JSONDict:
"""Return supplemental values used when formatting disambiguation."""
return {}
@property
def disambig_string(self) -> str:
"""Build a display string from the candidate's disambiguation fields.
Merges base disambiguation data with instance-specific field values,
then formats them as a comma-separated string in field definition order.
"""
data = {
k: self.info[k] for k in self.disambig_fields
} | self.base_disambig_data
return ", ".join(str(data[k]) for k in self.disambig_fields)
@dataclass
class AlbumMatch(Match):
"""Represent an album candidate together with its item-to-track mapping."""
disambig_fields_key = "album_disambig_fields"
info: AlbumInfo
mapping: dict[Item, TrackInfo]
extra_items: list[Item] = field(default_factory=list)
extra_tracks: list[TrackInfo] = field(default_factory=list)
def __post_init__(self) -> None:
"""Notify listeners when an album candidate has been matched."""
plugins.send("album_matched", match=self)
@property
def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]:
"""Return matched items together with their selected track metadata."""
return list(self.mapping.items())
@property
def items(self) -> list[Item]:
"""Return the items that participate in this album match."""
return [i for i, _ in self.item_info_pairs]
@property
def base_disambig_data(self) -> JSONDict:
"""Return album-specific values used in disambiguation displays."""
return {
"media": (
f"{mediums}x{self.info.media}"
if (mediums := self.info.mediums) and mediums > 1
else self.info.media
)
}
@property
def merged_pairs(self) -> list[tuple[Item, JSONDict]]:
"""Generate item-data pairs with album-level fallback values."""
return [
(i, ti.merge_with_album(self.info))
for i, ti in self.item_info_pairs
]
def apply_metadata(self, from_scratch: bool | None = None) -> None:
"""Apply metadata to each of the items.
If ``from_scratch`` is provided, its value determines whether the
items existing metadata are cleared before applying new metadata.
Otherwise, the configured ``from_scratch`` setting is used.
"""
for item, data in self.merged_pairs:
if self.from_scratch(from_scratch):
item.clear()
item.update(data)
def apply_album_metadata(self, album: Album) -> None:
"""Apply album-level metadata to the Album object."""
album.update(self.info.item_data)
@dataclass
class TrackMatch(Match):
"""Represent a singleton candidate and the item it updates."""
disambig_fields_key = "singleton_disambig_fields"
info: TrackInfo
item: Item
@property
def base_disambig_data(self) -> JSONDict:
"""Return singleton-specific values used in disambiguation displays."""
return {
"index": f"Index {self.info.index}",
"track_alt": f"Track {self.info.track_alt}",
"album": (
f"[{self.info.album}]"
if (
config["import"]["singleton_album_disambig"].get()
and self.info.album
)
else ""
),
}
def apply_metadata(self, from_scratch: bool | None = None) -> None:
"""Apply metadata to the item.
If ``from_scratch`` is provided, its value determines whether the
item's existing metadata is cleared before applying new metadata.
Otherwise, the configured ``from_scratch`` setting is used.
"""
if self.from_scratch(from_scratch):
self.item.clear()
self.item.update(self.info.item_data)

View File

@@ -18,28 +18,30 @@ releases and tracks.
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from typing import TYPE_CHECKING, NamedTuple, TypeVar
from functools import cached_property
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypeVar
import lap
import numpy as np
from beets import config, logging, metadata_plugins
from beets import config, logging, metadata_plugins, plugins
from beets.util import get_most_common_tags
from .distance import VA_ARTISTS, distance, track_distance
from .hooks import AlbumMatch, Info, TrackMatch
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beets.library import Item
from beets.library import Album, Item
from .hooks import AlbumInfo, TrackInfo
from .distance import Distance
from .hooks import AlbumInfo, Info, TrackInfo
AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch)
Candidates = dict[Info.Identifier, AnyMatch]
JSONDict = dict[str, Any]
AnyMatch = TypeVar("AnyMatch", "TrackMatch", "AlbumMatch")
Candidates = dict[Info.Identifier, AnyMatch]
# Global logger.
log = logging.getLogger("beets")
@@ -59,16 +61,6 @@ class Recommendation(IntEnum):
strong = 3
# A structure for holding a set of possible matches to choose between. This
# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
# objects) and a recommendation value.
class Proposal(NamedTuple):
candidates: Sequence[AlbumMatch | TrackMatch]
recommendation: Recommendation
# Primary matching functionality.
@@ -103,6 +95,174 @@ def assign_items(
return list(mapping.items()), extra_items, extra_tracks
# Structures that compose all the information for a candidate match.
@dataclass
class Match:
"""Represent a chosen metadata candidate and its application behavior."""
disambig_fields_key: ClassVar[str]
distance: Distance
info: Info
def apply_metadata(self) -> None:
"""Apply this match's metadata to its target library objects."""
raise NotImplementedError
@cached_property
def type(self) -> str:
return self.info.type
@cached_property
def config_from_scratch(self) -> bool:
return bool(config["import"]["from_scratch"])
def from_scratch(self, override: bool | None) -> bool:
if override is not None:
return override
return self.config_from_scratch
@property
def disambig_fields(self) -> Sequence[str]:
"""Return configured disambiguation fields that exist on this match."""
chosen_fields = config["match"][self.disambig_fields_key].as_str_seq()
valid_fields = [f for f in chosen_fields if f in self.info]
if missing_fields := set(chosen_fields) - set(valid_fields):
log.warning(
"Disambiguation string keys {} do not exist.", missing_fields
)
return valid_fields
@property
def base_disambig_data(self) -> JSONDict:
"""Return supplemental values used when formatting disambiguation."""
return {}
@property
def disambig_string(self) -> str:
"""Build a display string from the candidate's disambiguation fields.
Merges base disambiguation data with instance-specific field values,
then formats them as a comma-separated string in field definition order.
"""
data = {
k: self.info[k] for k in self.disambig_fields
} | self.base_disambig_data
return ", ".join(str(data[k]) for k in self.disambig_fields)
@dataclass
class AlbumMatch(Match):
"""Represent an album candidate together with its item-to-track mapping."""
disambig_fields_key = "album_disambig_fields"
info: AlbumInfo
mapping: dict[Item, TrackInfo]
extra_items: list[Item] = field(default_factory=list)
extra_tracks: list[TrackInfo] = field(default_factory=list)
def __post_init__(self) -> None:
"""Notify listeners when an album candidate has been matched."""
plugins.send("album_matched", match=self)
@property
def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]:
"""Return matched items together with their selected track metadata."""
return list(self.mapping.items())
@property
def items(self) -> list[Item]:
"""Return the items that participate in this album match."""
return [i for i, _ in self.item_info_pairs]
@property
def base_disambig_data(self) -> JSONDict:
"""Return album-specific values used in disambiguation displays."""
return {
"media": (
f"{mediums}x{self.info.media}"
if (mediums := self.info.mediums) and mediums > 1
else self.info.media
)
}
@property
def merged_pairs(self) -> list[tuple[Item, JSONDict]]:
"""Generate item-data pairs with album-level fallback values."""
return [
(i, ti.merge_with_album(self.info))
for i, ti in self.item_info_pairs
]
def apply_metadata(self, from_scratch: bool | None = None) -> None:
"""Apply metadata to each of the items.
If ``from_scratch`` is provided, its value determines whether the
items existing metadata are cleared before applying new metadata.
Otherwise, the configured ``from_scratch`` setting is used.
"""
for item, data in self.merged_pairs:
if self.from_scratch(from_scratch):
item.clear()
item.update(data)
def apply_album_metadata(self, album: Album) -> None:
"""Apply album-level metadata to the Album object."""
album.update(self.info.item_data)
@dataclass
class TrackMatch(Match):
"""Represent a singleton candidate and the item it updates."""
disambig_fields_key = "singleton_disambig_fields"
info: TrackInfo
item: Item
@property
def base_disambig_data(self) -> JSONDict:
"""Return singleton-specific values used in disambiguation displays."""
return {
"index": f"Index {self.info.index}",
"track_alt": f"Track {self.info.track_alt}",
"album": (
f"[{self.info.album}]"
if (
config["import"]["singleton_album_disambig"].get()
and self.info.album
)
else ""
),
}
def apply_metadata(self, from_scratch: bool | None = None) -> None:
"""Apply metadata to the item.
If ``from_scratch`` is provided, its value determines whether the
item's existing metadata is cleared before applying new metadata.
Otherwise, the configured ``from_scratch`` setting is used.
"""
if self.from_scratch(from_scratch):
self.item.clear()
self.item.update(self.info.item_data)
# A structure for holding a set of possible matches to choose between. This
# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
# objects) and a recommendation value.
class Proposal(NamedTuple):
candidates: Sequence[AlbumMatch | TrackMatch]
recommendation: Recommendation
def match_by_id(album_id: str | None, consensus: bool) -> Iterable[AlbumInfo]:
"""Return album candidates for the given album id.

View File

@@ -28,8 +28,7 @@ from typing import TYPE_CHECKING, Any
import mediafile
from beets import config, library, plugins, util
from beets.autotag.hooks import AlbumMatch
from beets.autotag.match import tag_album, tag_item
from beets.autotag import AlbumMatch, tag_album, tag_item
from beets.dbcore.query import PathQuery
from beets.util import extension
from beets.util.extension import remux_mpeglayer3_wav
@@ -39,8 +38,7 @@ from .state import ImportState
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beets.autotag.hooks import TrackMatch
from beets.autotag.match import Recommendation
from beets.autotag import Recommendation, TrackMatch
from .session import ImportSession

View File

@@ -36,7 +36,8 @@ QueryType = Literal["album", "track"]
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Iterator, Sequence
from .autotag.hooks import AlbumInfo, Item, TrackInfo
from .autotag import AlbumInfo, TrackInfo
from .library.models import Item
# Global logger.
log = logging.getLogger("beets")

View File

@@ -46,7 +46,7 @@ from mediafile import Image, MediaFile
import beets
import beets.plugins
from beets import importer, logging, util
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.importer import ImportSession
from beets.library import Item, Library
from beets.test import _common
@@ -162,6 +162,14 @@ class TestHelper(RunMixin, ConfigMixin):
fixtures.
"""
@pytest.fixture(autouse=True)
def setup(self):
self.setup_beets()
try:
yield
finally:
self.teardown_beets()
lib: Library
resource_path = Path(os.fsdecode(_common.RSRC)) / "full.mp3"
@@ -407,27 +415,9 @@ class BeetsTestCase(unittest.TestCase, TestHelper):
completes. Also provides some additional assertion methods, a
temporary directory, and a DummyIO.
DEPRECATED: Use pytest + PytestTestHelper instead.
DEPRECATED: Use TestHelper instead.
"""
def setUp(self):
self.setup_beets()
def tearDown(self):
self.teardown_beets()
class PytestTestHelper(TestHelper):
"""Same as the BeetsTestCase unittest setup but for pytest."""
@pytest.fixture(autouse=True)
def setup(self):
self.setup_beets()
try:
yield
finally:
self.teardown_beets()
class ItemInDBTestCase(BeetsTestCase):
"""A test case that includes an in-memory library object (`lib`) and
@@ -488,13 +478,13 @@ class PluginMixin(ConfigMixin):
class PluginTestCase(PluginMixin, BeetsTestCase):
"""
DEPRECATED: Use pytest + PytestPluginTestHelper instead.
DEPRECATED: Use PluginTestHelper instead.
"""
pass
class PytestPluginTestHelper(PluginMixin, PytestTestHelper):
class PluginTestHelper(PluginMixin, TestHelper):
"""Helper mixin for pytest-based plugin tests.
This mixin provides the standard beets test setup and automatically
@@ -502,7 +492,7 @@ class PytestPluginTestHelper(PluginMixin, PytestTestHelper):
.. code-block:: python
class TestMyPlugin(PytestPluginTestHelper):
class TestMyPlugin(PluginTestHelper):
plugin: ClassVar[str] = "myplugin"
"""

View File

@@ -7,7 +7,7 @@ from functools import cached_property
from typing import TYPE_CHECKING
from beets import config, ui
from beets.autotag.hooks import TrackInfo
from beets.autotag import TrackInfo
from beets.util import displayable_path
from beets.util.color import colorize
from beets.util.diff import colordiff
@@ -17,7 +17,7 @@ from beets.util.units import human_seconds_short
if TYPE_CHECKING:
import confuse
from beets.autotag.hooks import AlbumMatch, Match, TrackMatch
from beets.autotag import AlbumMatch, Match, TrackMatch
from beets.library.models import Item
from beets.util.color import ColorName

View File

@@ -4,8 +4,14 @@ from collections import Counter
from itertools import chain
from beets import config, importer, logging, plugins, ui
from beets.autotag.hooks import AlbumMatch, TrackMatch
from beets.autotag.match import Proposal, Recommendation, tag_album, tag_item
from beets.autotag import (
AlbumMatch,
Proposal,
Recommendation,
TrackMatch,
tag_album,
tag_item,
)
from beets.util import PromptChoice, displayable_path
from beets.util.color import colorize
from beets.util.units import human_bytes, human_seconds_short

View File

@@ -32,7 +32,7 @@ from requests_oauthlib.oauth1_session import (
import beets
import beets.ui
from beets import config
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.exceptions import UserError
from beets.metadata_plugins import MetadataSourcePlugin
from beets.util import unique_list

View File

@@ -18,7 +18,7 @@ import cProfile
import timeit
from beets import importer, plugins, ui
from beets.autotag.match import tag_album
from beets.autotag import tag_album
from beets.plugins import BeetsPlugin
from beets.util.functemplate import Template
from beets.util.pathformats import PF_KEY_DEFAULT

View File

@@ -15,8 +15,7 @@
"""Update library's tags using Beatport."""
from beets import library, ui, util
from beets.autotag.distance import Distance
from beets.autotag.hooks import AlbumMatch, TrackMatch
from beets.autotag import AlbumMatch, Distance, TrackMatch
from beets.plugins import BeetsPlugin, apply_item_changes
from beets.util.deprecation import deprecate_for_user

View File

@@ -28,7 +28,7 @@ import acoustid
import confuse
from beets import config, ui, util
from beets.autotag.distance import Distance
from beets.autotag import Distance
from beets.exceptions import UserError
from beets.metadata_plugins import MetadataSourcePlugin, get_metadata_source
from beets.util.color import colorize
@@ -36,7 +36,7 @@ from beets.util.color import colorize
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from beets.autotag.hooks import TrackInfo
from beets.autotag import TrackInfo
from beets.library.models import Item
from beetsplug.musicbrainz import MusicBrainzPlugin

View File

@@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, ClassVar
import requests
from beets import config, ui
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.dbcore import types
from beets.exceptions import UserError
from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin

View File

@@ -37,8 +37,7 @@ from requests.exceptions import ConnectionError
import beets
import beets.ui
from beets import config, util
from beets.autotag.distance import string_dist
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo, string_dist
from beets.exceptions import UserError
from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin

View File

@@ -29,7 +29,7 @@ from .types import ArtistInfo
if TYPE_CHECKING:
from confuse import ConfigView
from beets.autotag.hooks import TrackInfo
from beets.autotag import TrackInfo
from . import DiscogsPlugin
from .types import Artist, Track, TracklistInfo

View File

@@ -19,7 +19,7 @@ from typing import TYPE_CHECKING
from typing_extensions import NotRequired, TypedDict
if TYPE_CHECKING:
from beets.autotag.hooks import TrackInfo
from beets.autotag import TrackInfo
class ReleaseFormat(TypedDict):

View File

@@ -34,7 +34,7 @@ from bs4 import BeautifulSoup
from unidecode import unidecode
from beets import plugins, ui
from beets.autotag.distance import string_dist
from beets.autotag import string_dist
from beets.dbcore import types
from beets.dbcore.query import FalseQuery
from beets.library import Item, parse_query_string
@@ -44,7 +44,7 @@ from beets.util.lyrics import INSTRUMENTAL_LYRICS, Lyrics
from ._utils.requests import HTTPNotFoundError, RequestHandler
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from collections.abc import Callable, Iterable, Iterator
import confuse
@@ -60,6 +60,8 @@ if TYPE_CHECKING:
TranslatorAPI,
)
HtmlTransformer = Callable[[str], str]
class CaptchaError(requests.exceptions.HTTPError):
def __init__(self, *args, **kwargs) -> None:
@@ -444,11 +446,20 @@ class MusiXmatch(Backend):
return Lyrics(lyrics, self.__class__.name, url)
def apply_transforms(html: str, methods: Iterable[HtmlTransformer]) -> str:
for method in methods:
html = method(html)
return html
class Html:
collapse_space = partial(re.compile(r"(^| ) +", re.M).sub, r"\1")
expand_br = partial(re.compile(r"\s*<br[^>]*>\s*", re.I).sub, "\n")
#: two newlines between paragraphs on the same line (musica, letras.mus.br)
merge_blocks = partial(re.compile(r"(?<!>)</p><p[^>]*>").sub, "\n\n")
merge_blocks = partial(
re.compile(r"(?<!>)(</p><p[^>]*>)+", re.S).sub, "\n\n"
)
#: a single new line between paragraphs on separate lines
#: (paroles.net, sweetslyrics.com, lacoccinelle.net)
merge_lines = partial(re.compile(r"</p>\s+<p[^>]*>(?!___)").sub, "\n")
@@ -458,6 +469,26 @@ class Html:
)
#: remove Google Ads tags (musica.com)
remove_aside = partial(re.compile("<aside .+?</aside>").sub, "")
#: remove inline script blocks that split lyrics paragraphs
remove_scripts = partial(
re.compile(r"<script\b[^>]*>.*?</script\b[^>]*>", re.I | re.S).sub, ""
)
#: remove comments that split lyrics paragraphs
remove_comments = partial(re.compile(r"<!--.*?-->", re.S).sub, "")
#: remove title-only paragraph from the musica.com lyrics block
remove_musica_title = partial(
re.compile(
r"(<div\s+id=['\"]letra['\"][^>]*>.*?)"
r"<p>\s*<strong>[^<]+</strong>\s*</p>",
re.I | re.S,
).sub,
r"\1",
)
#: remove non-lyrics explanation blocks (musica.com)
remove_significado = partial(
re.compile(r"<div\s+id=['\"]significado['\"][^>]*>.*", re.I | re.S).sub,
"",
)
#: remove adslot-Content_1 div from the lyrics text (paroles.net)
remove_adslot = partial(
re.compile(r"\n</div>[^\n]+-- Content_\d+ --.*?\n<div>", re.S).sub, "\n"
@@ -470,22 +501,32 @@ class Html:
@classmethod
def normalize_space(cls, text: str) -> str:
text = unescape(text).replace("\r", "").replace("\xa0", " ")
return cls.collapse_space(cls.expand_br(text))
return apply_transforms(text, [cls.collapse_space, cls.expand_br])
@classmethod
def remove_ads(cls, text: str) -> str:
return cls.remove_adslot(cls.remove_aside(text))
return apply_transforms(text, [cls.remove_aside, cls.remove_adslot])
@classmethod
def merge_paragraphs(cls, text: str) -> str:
return cls.merge_blocks(cls.merge_lines(cls.remove_empty_tags(text)))
return apply_transforms(
text, [cls.remove_empty_tags, cls.merge_lines, cls.merge_blocks]
)
class SoupMixin:
@classmethod
def pre_process_html(cls, html: str) -> str:
"""Pre-process the HTML content before scraping."""
return Html.normalize_space(html)
return apply_transforms(
html,
[
Html.normalize_space,
Html.remove_significado,
Html.remove_scripts,
Html.remove_musica_title,
],
)
@classmethod
def get_soup(cls, html: str) -> BeautifulSoup:
@@ -679,8 +720,16 @@ class Google(SearchBackend):
@classmethod
def pre_process_html(cls, html: str) -> str:
"""Pre-process the HTML content before scraping."""
html = Html.remove_ads(super().pre_process_html(html))
return Html.remove_formatting(Html.merge_paragraphs(html))
return apply_transforms(
html,
[
super().pre_process_html,
Html.remove_ads,
Html.remove_comments,
Html.merge_paragraphs,
Html.remove_formatting,
],
)
def get_text(self, *args, **kwargs) -> str:
"""Handle an error so that we can continue with the next URL."""

View File

@@ -25,9 +25,7 @@ import mediafile
from typing_extensions import override
from beets import config
from beets.autotag.distance import distance
from beets.autotag.hooks import AlbumInfo
from beets.autotag.match import assign_items
from beets.autotag import AlbumInfo, assign_items, distance
from beets.plugins import find_plugins
from beets.util.id_extractors import extract_release_id
from beetsplug.musicbrainz import (
@@ -39,8 +37,7 @@ from beetsplug.musicbrainz import (
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beets.autotag.distance import Distance
from beets.autotag.hooks import AlbumMatch
from beets.autotag import AlbumMatch, Distance
from beets.library import Item
from ._utils.musicbrainz import (

View File

@@ -24,7 +24,7 @@ implemented by MusicBrainz yet.
import subprocess
from beets import ui
from beets.autotag.match import Recommendation
from beets.autotag import Recommendation
from beets.plugins import BeetsPlugin
from beets.util import PromptChoice, displayable_path
from beetsplug.info import print_data

View File

@@ -17,8 +17,7 @@
from collections import defaultdict
from beets import library, metadata_plugins, ui, util
from beets.autotag.distance import Distance
from beets.autotag.hooks import AlbumMatch, TrackMatch
from beets.autotag import AlbumMatch, Distance, TrackMatch
from beets.plugins import BeetsPlugin, apply_item_changes

View File

@@ -27,7 +27,7 @@ from confuse.exceptions import NotFoundError
from typing_extensions import NotRequired
from beets import config, plugins, util
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin
from beets.util.deprecation import deprecate_for_user
from beets.util.id_extractors import extract_release_id

View File

@@ -34,7 +34,7 @@ import confuse
import requests
from beets import ui
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.dbcore import types
from beets.exceptions import UserError
from beets.library import Library

View File

@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, overload
import confuse
from beets import ui
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.exceptions import UserError
from beets.logging import getLogger
from beets.metadata_plugins import MetadataSourcePlugin

View File

@@ -25,11 +25,11 @@ from typing import TYPE_CHECKING, TypedDict
from titlecase import titlecase
from beets import ui
from beets.autotag.hooks import AlbumInfo
from beets.autotag import AlbumInfo
from beets.plugins import BeetsPlugin
if TYPE_CHECKING:
from beets.autotag.hooks import Info
from beets.autotag import Info
from beets.importer import ImportSession, ImportTask
from beets.library import Item

View File

@@ -69,10 +69,15 @@ Bug fixes
- :doc:`plugins/fetchart`: Catch ``OSError`` in ``_set_art`` so that permission
errors (e.g. a file locked by another process) are logged as warnings instead
of crashing beets. :bug:`6193`
- :doc:`plugins/lyrics`: Improve Musica.com lyric scraping so fetched lyrics no
longer omit the opening verse or include non-lyric page content.
..
For plugin developers
~~~~~~~~~~~~~~~~~~~~~
For plugin developers
~~~~~~~~~~~~~~~~~~~~~
- Plugin authors can import all autotagger helpers directly from
``beets.autotag``, including match classes, distance helpers, and
``assign_items``, without relying on lower-level autotag modules.
Other changes
~~~~~~~~~~~~~

View File

@@ -29,7 +29,7 @@ Here`s a minimal example:
# beetsplug/myawesomeplugin.py
from typing import Sequence
from beets.autotag.hooks import Item
from beets.library import Item
from beets.metadata_plugins import MetadataSourcePlugin

View File

@@ -2,13 +2,14 @@ import re
import pytest
from beets.autotag.distance import (
from beets.autotag import (
AlbumInfo,
Distance,
TrackInfo,
distance,
string_dist,
track_distance,
)
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.library import Item
from beets.metadata_plugins import MetadataSourcePlugin, get_penalty
from beets.plugins import BeetsPlugin

View File

@@ -20,11 +20,11 @@ from functools import cached_property
import pytest
from beets.autotag.distance import Distance
from beets.autotag.hooks import (
from beets.autotag import (
AlbumInfo,
AlbumMatch,
AttrDict,
Distance,
TrackInfo,
TrackMatch,
correct_list_fields,

View File

@@ -3,8 +3,13 @@ from typing import ClassVar
import pytest
from beets import metadata_plugins
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag.match import assign_items, tag_album, tag_item
from beets.autotag import (
AlbumInfo,
TrackInfo,
assign_items,
tag_album,
tag_item,
)
from beets.library import Item

View File

@@ -5,7 +5,7 @@ from functools import cache
import pytest
from beets.autotag.distance import Distance
from beets.autotag import Distance
from beets.dbcore.query import Query
from beets.test._common import DummyIO
from beets.test.helper import ConfigMixin

View File

@@ -426,6 +426,11 @@ lyrics_pages = [
LyricsPage.make(
"https://www.musica.com/letras.asp?letra=59862",
"""
Lady Madonna, children at your feet
Wonder how you manage to make ends meet
Who finds the money when you pay the rent?
Did you think that money was heaven sent?
Friday night arrives without a suitcase
Sunday morning creeping like a nun
Monday's child has learned to tie his bootlace

View File

@@ -28,11 +28,10 @@ from requests_mock import ANY as ANYREEQUEST
from requests_mock.exceptions import NoMockAddress
from beets import config, importer, logging, util
from beets.autotag.distance import Distance
from beets.autotag.hooks import AlbumInfo, AlbumMatch
from beets.autotag import AlbumInfo, AlbumMatch, Distance
from beets.library import Album
from beets.test import _common
from beets.test.helper import FetchImageHelper, PytestTestHelper
from beets.test.helper import FetchImageHelper, TestHelper
from beets.util import clean_module_tempdir, syspath
from beets.util.artresizer import ArtResizer
from beetsplug import fetchart
@@ -68,7 +67,7 @@ class DummyRemoteArtSource(fetchart.RemoteArtSource):
return iter(())
class UseThePlugin(PytestTestHelper):
class UseThePlugin(TestHelper):
modules = (fetchart.__name__, ArtResizer.__module__)
@pytest.fixture(autouse=True)

View File

@@ -1,27 +1,19 @@
import pytest
from beets.test.helper import ImportHelper, PluginMixin
from beets.test.helper import ImportHelper, PluginTestHelper
pytestmark = pytest.mark.requires_import("librosa")
class TestAutoBPMPlugin(PluginMixin, ImportHelper):
class TestAutoBPMPlugin(ImportHelper, PluginTestHelper):
plugin = "autobpm"
@pytest.fixture(scope="class", name="lib")
def fixture_lib(self):
self.setup_beets()
yield self.lib
self.teardown_beets()
@pytest.fixture(scope="class")
@pytest.fixture
def item(self):
return self.add_item_fixture()
@pytest.fixture(scope="class")
def importer(self, lib):
@pytest.fixture
def importer(self):
self.import_media = []
self.prepare_album_for_import(1)
track = self.import_media[0]
@@ -29,27 +21,27 @@ class TestAutoBPMPlugin(PluginMixin, ImportHelper):
track.save()
return self.setup_importer(autotag=False)
def test_command(self, lib, item):
def test_command(self, item):
item.bpm = None
item.store()
self.run_command("autobpm", lib=lib)
self.run_command("autobpm")
item.load()
assert item.bpm == 117
def test_command_force(self, lib, item):
def test_command_force(self, item):
item.bpm = 10
item.store()
self.run_command("autobpm", lib=lib)
self.run_command("autobpm")
item.load()
assert item.bpm == 10
self.run_command("autobpm", "--force", lib=lib)
self.run_command("autobpm", "--force")
item.load()
assert item.bpm == 117
def test_command_overwrite(self, lib, item):
def test_command_overwrite(self, item):
item.bpm = 10
item.store()
@@ -57,23 +49,23 @@ class TestAutoBPMPlugin(PluginMixin, ImportHelper):
self.config[self.plugin]["overwrite"] = True
self.load_plugins()
self.run_command("autobpm", lib=lib)
self.run_command("autobpm")
item.load()
assert item.bpm == 117
def test_command_quiet(self, lib, item, caplog):
def test_command_quiet(self, item, caplog):
item.bpm = 10
item.store()
with caplog.at_level("DEBUG"):
self.run_command("autobpm", "--quiet", lib=lib)
self.run_command("autobpm", "--quiet")
assert not any("already exists" in msg for msg in caplog.messages)
with caplog.at_level("DEBUG"):
self.run_command("autobpm", lib=lib)
self.run_command("autobpm")
assert any("already exists" in msg for msg in caplog.messages)
def test_import(self, lib, importer):
def test_import(self, importer):
importer.run()
assert lib.items().get().bpm == 117
assert self.lib.items().get().bpm == 117

View File

@@ -17,7 +17,7 @@ from unittest.mock import MagicMock, patch
import pytest
from beets import metadata_plugins
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.library import Item
from beets.test.helper import ImportTestCase, IOMixin, PluginMixin
from beetsplug import chroma

View File

@@ -37,20 +37,8 @@ def _artist(name: str, **kwargs):
} | kwargs
class PytestTestHelper(TestHelper):
"""Same as the BeetsTestCase unittest setup but for pytest."""
@pytest.fixture(autouse=True)
def setup(self):
self.setup_beets()
try:
yield
finally:
self.teardown_beets()
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
class TestDGAlbumInfo(PytestTestHelper):
class TestDGAlbumInfo(TestHelper):
def _make_release(self, tracks=None):
"""Returns a Bag that mimics a discogs_client.Release. The list
of elements on the returned Bag is incomplete, including just
@@ -468,7 +456,7 @@ class TestDGAlbumInfo(PytestTestHelper):
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
class TestDGSearchQuery(PytestTestHelper):
class TestDGSearchQuery(TestHelper):
def test_default_search_filters_without_extra_tags(self):
"""Discogs search uses only the type filter when no extra_tags are set."""
plugin = DiscogsPlugin()

View File

@@ -32,7 +32,7 @@ from beets.test.helper import (
FetchImageHelper,
ImportHelper,
IOMixin,
PytestPluginTestHelper,
PluginTestHelper,
)
from beets.util import bytestring_path, displayable_path, syspath
from beets.util.artresizer import ArtResizer
@@ -77,7 +77,7 @@ def require_artresizer_compare(test):
return wrapper
class PytestImportHelper(PytestPluginTestHelper, ImportHelper):
class PytestImportHelper(PluginTestHelper, ImportHelper):
@pytest.fixture(autouse=True)
def setup_import_helper(self, setup):
self.import_media = []

View File

@@ -16,265 +16,228 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypeAlias
import pytest
from beets.library.models import Album
from beets.test.helper import PluginTestCase
from beets.test.helper import PluginTestHelper
from beetsplug import ftintitle
if TYPE_CHECKING:
from collections.abc import Generator
from beets.library.models import Item
ConfigValue: TypeAlias = str | bool | list[str]
class FtInTitlePluginFunctional(PluginTestCase):
class TestFtInTitlePluginFunctional(PluginTestHelper):
plugin = "ftintitle"
@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: dict[str, ConfigValue] | None
) -> None:
cfg = {} if cfg is None else cfg
defaults = {
"drop": False,
"auto": True,
"keep_in_artist": False,
"custom_words": [],
}
env.config["ftintitle"].set(defaults)
env.config["ftintitle"].set(cfg)
def add_item(
env: FtInTitlePluginFunctional,
path: str,
artist: str,
title: str,
albumartist: str | None,
) -> Item:
return env.add_item(
path=path,
artist=artist,
artist_sort=artist,
title=title,
albumartist=albumartist,
@pytest.mark.parametrize(
"cfg, cmd_args, given, expected",
[
pytest.param(
{},
("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(
{},
("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(
{},
("ftintitle",),
("Alice", "Song 1", "George"),
("Alice", "Song 1"),
id="guest-artist-no-feature",
),
# ---- drop (-d) variants ----
pytest.param(
{},
("ftintitle", "-d"),
("Alice ft Bob", "Song 1", "Alice"),
("Alice", "Song 1"),
id="drop-self-ft",
),
pytest.param(
{},
("ftintitle", "-d"),
("Alice", "Song 1", "Alice"),
("Alice", "Song 1"),
id="drop-self-no-ft",
),
pytest.param(
{},
("ftintitle", "-d"),
("Alice ft Bob", "Song 1", "George"),
("Alice", "Song 1"),
id="drop-guest-ft",
),
pytest.param(
{},
("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",
),
# ---- custom_words variants ----
pytest.param(
{"format": "featuring {}", "custom_words": ["med"]},
("ftintitle",),
("Alice med Bob", "Song 1", "Alice"),
("Alice", "Song 1 featuring Bob"),
id="custom-feat-words",
),
pytest.param(
{
"format": "featuring {}",
"keep_in_artist": True,
"custom_words": ["med"],
},
("ftintitle",),
("Alice med Bob", "Song 1", "Alice"),
("Alice med Bob", "Song 1 featuring Bob"),
id="custom-feat-words-keep-in-artists",
),
pytest.param(
{
"format": "featuring {}",
"keep_in_artist": True,
"custom_words": ["med"],
},
("ftintitle", "-d"),
("Alice med Bob", "Song 1", "Alice"),
("Alice med Bob", "Song 1"),
id="custom-feat-words-keep-in-artists-drop-from-title",
),
# ---- preserve_album_artist variants ----
pytest.param(
{"format": "feat. {}", "preserve_album_artist": True},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice"),
("Alice", "Song 1 feat. Bob"),
id="skip-if-artist-and-album-artists-is-the-same-different-match",
),
pytest.param(
{"format": "feat. {}", "preserve_album_artist": False},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice"),
("Alice", "Song 1 feat. Bob"),
id="skip-if-artist-and-album-artists-is-the-same-different-match-b",
),
pytest.param(
{"format": "feat. {}", "preserve_album_artist": True},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice feat. Bob"),
("Alice feat. Bob", "Song 1"),
id="skip-if-artist-and-album-artists-is-the-same-matching-match",
),
pytest.param(
{"format": "feat. {}", "preserve_album_artist": False},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice feat. Bob"),
("Alice", "Song 1 feat. Bob"),
id="skip-if-artist-and-album-artists-is-the-same-matching-match-b",
),
# ---- titles with brackets/parentheses ----
pytest.param(
{"format": "ft. {}", "bracket_keywords": ["mix"]},
("ftintitle",),
("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"),
("Alice", "Song 1 ft. Bob (Club Mix)"),
id="ft-inserted-before-matching-bracket-keyword",
),
pytest.param(
{"format": "ft. {}", "bracket_keywords": ["nomatch"]},
("ftintitle",),
("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"),
("Alice", "Song 1 (Club Remix) ft. Bob"),
id="ft-inserted-at-end-no-bracket-keyword-match",
),
],
)
def test_ftintitle_functional(
self,
cfg: dict[str, str | bool | list[str]],
cmd_args: tuple[str, ...],
given: tuple[str, str, str | None],
expected: tuple[str, str],
) -> None:
config = {
"drop": False,
"auto": True,
"keep_in_artist": False,
"custom_words": [],
**cfg,
}
artist, title, albumartist = given
item = self.add_item(
path="/",
artist=artist,
artist_sort=artist,
title=title,
albumartist=albumartist,
)
@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",
),
# ---- custom_words variants ----
pytest.param(
{"format": "featuring {}", "custom_words": ["med"]},
("ftintitle",),
("Alice med Bob", "Song 1", "Alice"),
("Alice", "Song 1 featuring Bob"),
id="custom-feat-words",
),
pytest.param(
{
"format": "featuring {}",
"keep_in_artist": True,
"custom_words": ["med"],
},
("ftintitle",),
("Alice med Bob", "Song 1", "Alice"),
("Alice med Bob", "Song 1 featuring Bob"),
id="custom-feat-words-keep-in-artists",
),
pytest.param(
{
"format": "featuring {}",
"keep_in_artist": True,
"custom_words": ["med"],
},
("ftintitle", "-d"),
("Alice med Bob", "Song 1", "Alice"),
("Alice med Bob", "Song 1"),
id="custom-feat-words-keep-in-artists-drop-from-title",
),
# ---- preserve_album_artist variants ----
pytest.param(
{"format": "feat. {}", "preserve_album_artist": True},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice"),
("Alice", "Song 1 feat. Bob"),
id="skip-if-artist-and-album-artists-is-the-same-different-match",
),
pytest.param(
{"format": "feat. {}", "preserve_album_artist": False},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice"),
("Alice", "Song 1 feat. Bob"),
id="skip-if-artist-and-album-artists-is-the-same-different-match-b",
),
pytest.param(
{"format": "feat. {}", "preserve_album_artist": True},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice feat. Bob"),
("Alice feat. Bob", "Song 1"),
id="skip-if-artist-and-album-artists-is-the-same-matching-match",
),
pytest.param(
{"format": "feat. {}", "preserve_album_artist": False},
("ftintitle",),
("Alice feat. Bob", "Song 1", "Alice feat. Bob"),
("Alice", "Song 1 feat. Bob"),
id="skip-if-artist-and-album-artists-is-the-same-matching-match-b",
),
# ---- titles with brackets/parentheses ----
pytest.param(
{"format": "ft. {}", "bracket_keywords": ["mix"]},
("ftintitle",),
("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"),
("Alice", "Song 1 ft. Bob (Club Mix)"),
id="ft-inserted-before-matching-bracket-keyword",
),
pytest.param(
{"format": "ft. {}", "bracket_keywords": ["nomatch"]},
("ftintitle",),
("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"),
("Alice", "Song 1 (Club Remix) ft. Bob"),
id="ft-inserted-at-end-no-bracket-keyword-match",
),
],
)
def test_ftintitle_functional(
env: FtInTitlePluginFunctional,
cfg: dict[str, str | bool | list[str]] | None,
cmd_args: tuple[str, ...],
given: tuple[str, str, str | None],
expected: tuple[str, str],
) -> None:
set_config(env, cfg)
ftintitle.FtInTitlePlugin()
with self.configure_plugin(config):
self.run_command(*cmd_args)
artist, title, albumartist = given
item = add_item(env, "/", artist, title, albumartist)
item.load()
env.run_command(*cmd_args)
item.load()
expected_artist, expected_title = expected
assert item["artist"] == expected_artist
assert item["title"] == expected_title
expected_artist, expected_title = expected
assert item["artist"] == expected_artist
assert item["title"] == expected_title
@pytest.mark.parametrize(

View File

@@ -22,13 +22,13 @@ from typing import TYPE_CHECKING, ClassVar
import pytest
from beets import plugins
from beets.test.helper import PytestPluginTestHelper
from beets.test.helper import PluginTestHelper
if TYPE_CHECKING:
from collections.abc import Callable
class HookTestCase(PytestPluginTestHelper):
class HookTestCase(PluginTestHelper):
plugin = "hook"
preload_plugin = False

View File

@@ -165,8 +165,8 @@ class TestHtml:
assert lyrics.Html.normalize_space(initial) == expected
def test_scrape_merge_paragraphs(self):
text = "one</p> <p class='myclass'>two</p><p>three"
expected = "one\ntwo\n\nthree"
text = 'one</p><p class="myclass"></p><p>two</p><p>three'
expected = "one\n\ntwo\n\nthree"
assert lyrics.Html.merge_paragraphs(text) == expected
@@ -451,20 +451,24 @@ class TestLyricsSources(LyricsBackendTest):
"beetsplug.lyrics.LyricsRequestHandler.create_session",
lambda _: requests.Session(),
)
expected_lyrics = Lyrics(
lyrics_page.lyrics,
lyrics_page.backend,
url=lyrics_page.url,
language=lyrics_page.language,
)
assert lyrics_plugin.find_lyrics(
actual_lyrics = lyrics_plugin.find_lyrics(
Item(
artist=lyrics_page.artist,
title=lyrics_page.track_title,
album="",
length=186.0,
)
) == Lyrics(
lyrics_page.lyrics,
lyrics_page.backend,
url=lyrics_page.url,
language=lyrics_page.language,
)
assert actual_lyrics
assert actual_lyrics.text == expected_lyrics.text
assert actual_lyrics == expected_lyrics
class TestGoogleLyrics(LyricsBackendTest):

View File

@@ -24,14 +24,6 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
self.config["musicbrainz"]["pass"] = "testpass"
self.config["mbcollection"]["collection"] = self.COLLECTION_ID
@pytest.fixture(autouse=True)
def helper(self):
self.setup_beets()
yield self
self.teardown_beets()
@pytest.mark.parametrize(
"user_collections,expectation",
[
@@ -66,7 +58,7 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
with expectation:
mbcollection.MusicBrainzCollectionPlugin().collection
def test_mbupdate(self, helper, requests_mock, monkeypatch):
def test_mbupdate(self, requests_mock, monkeypatch):
"""Verify mbupdate sync of a MusicBrainz collection with the library.
This test ensures that the command:
@@ -85,7 +77,7 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
]:
helper.lib.add(Album(mb_albumid=mb_albumid))
self.lib.add(Album(mb_albumid=mb_albumid))
# The relevant collection
requests_mock.get(
@@ -138,13 +130,11 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
re.compile(rf".*{collection_releases}/not_in_library")
)
helper.run_command("mbupdate", "--remove")
self.run_command("mbupdate", "--remove")
assert requests_mock.call_count == 6
def test_mbupdate_logs_unauthorized_errors(
self, helper, requests_mock, caplog
):
def test_mbupdate_logs_unauthorized_errors(self, requests_mock, caplog):
response = requests.Response()
response.status_code = 401
requests_mock.get(
@@ -153,7 +143,7 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
)
with caplog.at_level("ERROR", logger="beets.mbcollection"):
helper.run_command("mbupdate")
self.run_command("mbupdate")
expected_message = (
"Failed to update MusicBrainz collection: HTTP Error: 401"

View File

@@ -6,8 +6,7 @@ from typing import TYPE_CHECKING
import pytest
from beets.autotag.distance import Distance
from beets.autotag.hooks import AlbumInfo, AlbumMatch, TrackInfo
from beets.autotag import AlbumInfo, AlbumMatch, Distance, TrackInfo
from beets.library import Item
from beets.test.helper import PluginMixin
from beetsplug.mbpseudo import (

View File

@@ -14,12 +14,12 @@
from unittest.mock import Mock, patch
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.library import Item
from beets.test.helper import PytestPluginTestHelper
from beets.test.helper import PluginTestHelper
class TestMbsyncCli(PytestPluginTestHelper):
class TestMbsyncCli(PluginTestHelper):
plugin = "mbsync"
@patch(

View File

@@ -6,7 +6,7 @@ from unittest.mock import patch
import pytest
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.library import Album, Item
from beets.test.helper import IOMixin, PluginMixin, TestHelper

View File

@@ -16,7 +16,7 @@
from unittest.mock import patch
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag import AlbumInfo, TrackInfo
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.test.helper import PluginTestCase

View File

@@ -36,8 +36,7 @@ import pytest
from mediafile import MediaFile
from beets import config, importer, logging, util
from beets.autotag.distance import Distance
from beets.autotag.hooks import AlbumInfo, AlbumMatch, TrackInfo
from beets.autotag import AlbumInfo, AlbumMatch, Distance, TrackInfo
from beets.importer.tasks import albums_in_dir
from beets.test import _common
from beets.test.helper import (

File diff suppressed because it is too large Load Diff

View File

@@ -39,13 +39,13 @@ from beets.test.helper import (
ImportHelper,
IOMixin,
PluginMixin,
PytestPluginTestHelper,
PluginTestHelper,
TerminalImportMixin,
)
from beets.util import PromptChoice, displayable_path, syspath
class TestPluginRegistration(IOMixin, PytestPluginTestHelper):
class TestPluginRegistration(IOMixin, PluginTestHelper):
class RatingPlugin(plugins.BeetsPlugin):
item_types: ClassVar[dict[str, types.Type]] = {
"rating": types.Float(),
@@ -97,7 +97,7 @@ class TestPluginRegistration(IOMixin, PytestPluginTestHelper):
assert out == "one; two; three\n"
class PytestImportHelper(ImportHelper, PytestPluginTestHelper):
class PytestImportHelper(ImportHelper, PluginTestHelper):
@pytest.fixture(autouse=True)
def setup_import_helper(self, setup):
self.import_media = []
@@ -179,7 +179,7 @@ class TestEvents(PytestImportHelper):
]
class TestListeners(PytestPluginTestHelper):
class TestListeners(PluginTestHelper):
def test_register(self):
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):

View File

@@ -5,8 +5,7 @@ from unittest.mock import Mock, patch
import pytest
from beets import config, library
from beets.autotag.hooks import AlbumInfo, AlbumMatch, TrackInfo
from beets.autotag.match import distance
from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, distance
from beets.exceptions import UserError
from beets.test import _common
from beets.test.helper import BeetsTestCase, IOMixin