autotagging: tidy up autotag module (#6687)

### What changed
- Move `Match`, `AlbumMatch`, and `TrackMatch` from
`beets.autotag.hooks` into `beets.autotag.match`.
- Expand `beets.autotag.__init__` to re-export main autotag types and
functions like `Distance`, `Info`, `Match`, `assign_items`, `tag_album`,
and `tag_item`.
- Update internal code, plugins, tests, and docs to import from
`beets.autotag` instead of reaching into deeper modules.

### Architecture impact
- `beets.autotag` now acts as the main public API for autotagging.
- `hooks.py` stays focused on metadata/info structures.
- `match.py` now owns match objects and matching behavior in one place.

### High-level benefit
- Import paths become simpler and more consistent.
- Internal module boundaries are clearer.
- Future refactors inside autotag should be safer because callers depend
on `beets.autotag`, not module internals.
This commit is contained in:
Šarūnas Nejus
2026-06-03 16:15:52 +01:00
committed by GitHub
41 changed files with 345 additions and 266 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
@@ -184,4 +186,6 @@ d4fd36a03384b0f71144932833d9c633c1f9545f
# Reformat using skip-magic-trailing-comma
adfb73b66dba0fc799880af176ef6f9ac0a7ee68
# Refactor test_library
6493d4dd018e08eb5d16d5381ebff47deea98bb1
6493d4dd018e08eb5d16d5381ebff47deea98bb1
# Import everything from parent beets.autotag
29580d2f00eb02f1ce77ebe3e1435804f7252ba1

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

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

@@ -67,10 +67,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,8 +28,7 @@ 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

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

@@ -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

@@ -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,7 +14,7 @@
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

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 (

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