diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index d4c47e4ec..aa001ffac 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -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 \ No newline at end of file +6493d4dd018e08eb5d16d5381ebff47deea98bb1 +# Import everything from parent beets.autotag +29580d2f00eb02f1ce77ebe3e1435804f7252ba1 diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 094ed9e9b..84d3f4fe4 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -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", ] diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 5a1806c8b..dc65fc6b0 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -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) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 9cda3884d..f8ccbb22a 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -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. diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index cbc12b62b..e31d581bb 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -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 diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 0bac956a0..7bcd30399 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -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") diff --git a/beets/test/helper.py b/beets/test/helper.py index cb66917ad..e2f6fda00 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -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 diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index b4903adae..187da0bda 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -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 diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index de8d09a18..5806b1b0f 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -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 diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 92e8839fd..c3506f7bf 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -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 diff --git a/beetsplug/bench.py b/beetsplug/bench.py index cc29b14cb..9df32af43 100644 --- a/beetsplug/bench.py +++ b/beetsplug/bench.py @@ -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 diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py index 6583e3fe8..c13ade036 100644 --- a/beetsplug/bpsync.py +++ b/beetsplug/bpsync.py @@ -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 diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index eb001d90e..0cd280b0a 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -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 diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 209ae035c..49b570aaa 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -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 diff --git a/beetsplug/discogs/__init__.py b/beetsplug/discogs/__init__.py index a1c95269b..fcf4c090e 100644 --- a/beetsplug/discogs/__init__.py +++ b/beetsplug/discogs/__init__.py @@ -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 diff --git a/beetsplug/discogs/states.py b/beetsplug/discogs/states.py index 9e1405a3f..9324b5024 100644 --- a/beetsplug/discogs/states.py +++ b/beetsplug/discogs/states.py @@ -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 diff --git a/beetsplug/discogs/types.py b/beetsplug/discogs/types.py index cbd95e901..14ef138b9 100644 --- a/beetsplug/discogs/types.py +++ b/beetsplug/discogs/types.py @@ -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): diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 8dbfed256..9aec77e86 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -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*]*>\s*", re.I).sub, "\n") #: two newlines between paragraphs on the same line (musica, letras.mus.br) - merge_blocks = partial(re.compile(r"(?)

]*>").sub, "\n\n") + merge_blocks = partial( + re.compile(r"(?)(

]*>)+", 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"

\s+]*>(?!___)").sub, "\n") @@ -458,6 +469,26 @@ class Html: ) #: remove Google Ads tags (musica.com) remove_aside = partial(re.compile("