diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index dc3d1a62fc..f38c1328bc 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -14,6 +14,7 @@ Match, Proposal, Recommendation, + SearchQuery, TrackMatch, assign_items, tag_album, @@ -40,6 +41,7 @@ def __getattr__(name: str): "Match", "Proposal", "Recommendation", + "SearchQuery", "Source", "TrackInfo", "TrackMatch", diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 6bd8635a9d..f42e792b8f 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -13,6 +13,7 @@ import numpy as np from beets import config, logging, metadata_plugins, plugins +from beets.util import Likelies from .distance import VA_ARTISTS, distance, track_distance @@ -380,72 +381,69 @@ def _add_candidate( ) +@dataclass(frozen=True) +class SearchQuery: + """User supplied search query""" + + title: str | None = None + artist: str | None = None + + @property + def va_likely(self) -> bool: + """Return True if the search query is likely a compilation.""" + return self.artist is not None and self.artist.lower() in VA_ARTISTS + + def tag_album( source: Source, - search_artist: str | None = None, - search_name: str | None = None, - search_ids: list[str] = [], + search_query: SearchQuery | None = None, + search_ids: list[str] | None = None, ) -> Proposal: - """Return `Proposal` containing `AlbumMatch` candidates. - - The `AlbumMatch` objects are generated by searching the metadata - backends. By default, the metadata of the items is used for the - search. This can be customized by setting the parameters. - `search_ids` is a list of metadata backend IDs: if specified, - it will restrict the candidates to those IDs, ignoring - `search_artist` and `search album`. The `mapping` field of the - album has the matched `items` as keys. + """Find metadata for a album. Return a `Proposal` containing `AlbumMatch` + candidates. The recommendation is calculated from the match quality of the candidates. """ - # Get current metadata. log.debug("Tagging {}", source.desc) # The output result, keys are (data_source, album_id) pairs, values are # AlbumMatch objects. candidates: Candidates[AlbumMatch] = {} + rec: Recommendation | None = None - # Search by explicit ID. - if search_ids: - log.debug("Searching for album IDs: {}", ", ".join(search_ids)) - for _info in metadata_plugins.albums_for_ids(search_ids): - _add_candidate(source, candidates, _info) - - # Use existing metadata or text search. - else: - # Try search based on current ID. - for info in match_by_id(source.id, source.id_consensus): + # Search by ids either user provided or discovered from the source. + albumids = search_ids or [t for t in [source.id] if t] + if albumids: + log.debug("Searching for album IDs: {}", ", ".join(albumids)) + for info in metadata_plugins.albums_for_ids(albumids): _add_candidate(source, candidates, info) - rec = _recommendation(list(candidates.values())) - log.debug("Album ID match recommendation is {}", rec) - if candidates and not config["import"]["timid"]: - # If we have a very good MBID match, return immediately. - # Otherwise, this match will compete against metadata-based - # matches. - if rec == Recommendation.strong: - log.debug("ID match.") - return Proposal(list(candidates.values()), rec) - - # Search terms. - if not (search_artist and search_name): - # No explicit search terms -- use current metadata. - search_artist, search_name = source.artist, source.name - log.debug("Search terms: {} - {}", search_artist, search_name) - - # Is this album likely to be a "various artist" release? - va_likely = source.va_likely or (search_artist.lower() in VA_ARTISTS) - log.debug("Album might be VA: {}", va_likely) - - # Get the results from the data sources. - for matched_candidate in metadata_plugins.candidates( - source.items, search_artist, search_name, va_likely - ): - _add_candidate(source, candidates, matched_candidate) + # If this is a good match, then don't keep searching. + rec = _recommendation(_sort_candidates(candidates.values())) + if rec == Recommendation.strong and not config["import"]["timid"]: + log.debug("Album ID match.") + return Proposal(_sort_candidates(candidates.values()), rec) + + # If ID provided by user, don't proceed with search. + if search_ids: + if candidates: + assert rec is not None + return Proposal(_sort_candidates(candidates.values()), rec) + log.debug( + "No candidates found for user-provided Album IDs. Skipping further search." + ) + return Proposal([], Recommendation.none) + + # Search by metadata, either user provided or discovered from the source. We + # let the plugins decide on their own how to handle user-provided search queries. + # This allows plugins to skip unnecessary and cases. It is hard to account for all + # these here so using the escape hatch in the plugins is a better approach. + for matched_candidate in metadata_plugins.candidates(source, search_query): + _add_candidate(source, candidates, matched_candidate) + # Sort by distance and return with recommendation. log.debug("Evaluating {} candidates.", len(candidates)) - # Sort and get the recommendation. candidates_sorted = _sort_candidates(candidates.values()) rec = _recommendation(candidates_sorted) return Proposal(candidates_sorted, rec) @@ -453,23 +451,21 @@ def tag_album( def tag_item( source: Source, - search_artist: str | None = None, - search_name: str | None = None, + search_query: SearchQuery | None = None, search_ids: list[str] | None = None, ) -> Proposal: """Find metadata for a single track. Return a `Proposal` consisting of `TrackMatch` objects. - - `search_artist` and `search_title` may be used to override the item - metadata in the search query. `search_ids` may be used for restricting the - search to a list of metadata backend IDs. """ + log.debug("Tagging {}", source.desc) + # Holds candidates found so far: keys are (data_source, track_id) pairs, # values TrackMatch objects candidates: Candidates[TrackMatch] = {} rec: Recommendation | None = None item = source.items[0] + # First, try matching by the external source ID. trackids = search_ids or [t for t in [source.id] if t] if trackids: @@ -489,17 +485,14 @@ def tag_item( if candidates: assert rec is not None return Proposal(_sort_candidates(candidates.values()), rec) + log.debug( + "No candidates found for user-provided Track IDs. Skipping further search." + ) return Proposal([], Recommendation.none) - # Search terms. - search_artist = search_artist or source.artist - search_name = search_name or source.name - log.debug("Item search terms: {} - {}", search_artist, search_name) - - # Get and evaluate candidate metadata. - for track_info in metadata_plugins.item_candidates( - item, search_artist, search_name - ): + # Search by metadata, either user provided or items from the source. We + # let the plugins decide on their own how to handle user-provided search queries. + for track_info in metadata_plugins.item_candidates(source, search_query): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.identifier] = TrackMatch(dist, track_info, item) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 124d4ab4e8..40f158da63 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -8,12 +8,14 @@ from __future__ import annotations import abc +import inspect import re from concurrent.futures import ThreadPoolExecutor, as_completed from contextlib import contextmanager from functools import cache, cached_property, wraps from typing import ( TYPE_CHECKING, + Any, Generic, Literal, NamedTuple, @@ -26,6 +28,7 @@ from beets import config, logging from beets.util import cached_classproperty +from beets.util.deprecation import deprecate_for_maintainers from beets.util.id_extractors import extract_release_id from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send @@ -36,7 +39,9 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator, Sequence - from .autotag import AlbumInfo, TrackInfo + from beets.util import Likelies + + from .autotag import AlbumInfo, SearchQuery, Source, TrackInfo from .library.models import Item # Global logger. @@ -75,61 +80,132 @@ def maybe_handle_plugin_error(plugin: MetadataSourcePlugin, method_name: str): def _yield_from_plugins( - func: Callable[..., Iterable[Ret]], -) -> Callable[..., Iterator[Ret]]: - method_name = func.__name__ - - def materialize( - plugin: MetadataSourcePlugin, method_name: str, *args, **kwargs - ) -> list[Ret]: - method = getattr(plugin, method_name) - return list(method(*args, **kwargs)) - - @wraps(func) - def wrapper(*args, **kwargs) -> Iterator[Ret]: - # Run plugin methods concurrently for faster I/O-bound lookups. - with ThreadPoolExecutor() as executor: - futures = { - executor.submit( - # Evaluate iterator with list such that results are ready when - # future.result() is called. - materialize, - plugin, - method_name, - *args, - **kwargs, - ): plugin - for plugin in find_metadata_source_plugins() - } + *, deprecate_converter: Callable[..., tuple[Any, ...]] | None = None +) -> Callable[[Callable[..., Iterable[Ret]]], Callable[..., Iterator[Ret]]]: + """Decorate a dispatcher to invoke the same method on every metadata + source plugin concurrently, yielding each plugin's results. + + When ``deprecate_converter`` is given, plugins whose methods still + declare the deprecated argument list are called with the current + arguments converted to the deprecated ones, along with a deprecation + warning. + """ + + def decorator( + func: Callable[..., Iterable[Ret]], + ) -> Callable[..., Iterator[Ret]]: + method_name = func.__name__ + + def materialize( + plugin: MetadataSourcePlugin, method_name: str, *args, **kwargs + ) -> list[Ret]: + method = getattr(plugin, method_name) + if deprecate_converter: + signature = inspect.signature(method) + try: + signature.bind(*args, **kwargs) + except TypeError: + # The plugin still implements the deprecated argument + # list: convert and warn. + deprecate_for_maintainers( + old=f"'{plugin.data_source}.{method_name}{signature}'" + ) + if not kwargs: + args = deprecate_converter(*args) + return list(method(*args, **kwargs)) + + @wraps(func) + def wrapper(*args, **kwargs) -> Iterator[Ret]: + # Run plugin methods concurrently for faster I/O-bound lookups. + with ThreadPoolExecutor() as executor: + futures = { + executor.submit( + # Evaluate iterator with list such that results are ready when + # future.result() is called. + materialize, + plugin, + method_name, + *args, + **kwargs, + ): plugin + for plugin in find_metadata_source_plugins() + } + + for future in as_completed(futures): + plugin = futures[future] + with maybe_handle_plugin_error(plugin, method_name): + yield from filter(None, future.result()) + + return wrapper + + return decorator + + +def _deprecate_candidates_args( + source: Source, search_query: SearchQuery | None = None +) -> tuple[Sequence[Item], str, str, bool]: + """Convert the new ``candidates`` arguments to the legacy signature. + + Legacy signature: + candidates(self, items: Sequence[Item], artist: str, album: str, va_likely: bool): + + .. deprecated:: 2.14.0 + This function will be removed in 3.0.0. + """ - for future in as_completed(futures): - plugin = futures[future] - with maybe_handle_plugin_error(plugin, method_name): - yield from filter(None, future.result()) + # Search query had precedence over source/likelies + if search_query is not None: + artist = search_query.artist or "" + album = search_query.title or "" + va_likely = search_query.va_likely + else: + artist = source.artist or "" + album = source.name or "" + va_likely = source.va_likely - return wrapper + return source.items, artist, album, va_likely @notify_info_yielded("albuminfo_received") -@_yield_from_plugins +@_yield_from_plugins(deprecate_converter=_deprecate_candidates_args) def candidates(*args, **kwargs) -> Iterator[AlbumInfo]: yield from () +def _deprecate_item_candidates_args( + source: Source, search_query: SearchQuery | None = None +) -> tuple[Item, str, str]: + """Convert the new ``item_candidates`` arguments to the legacy signature. + + Legacy signature: + item_candidates(self, item: Item, artist: str, title: str): + + .. deprecated:: 2.14.0 + This function will be removed in 3.0.0. + """ + if search_query is not None: + artist = search_query.artist or "" + title = search_query.title or "" + else: + artist = source.artist or "" + title = source.name or "" + return source.items[0], artist, title + + @notify_info_yielded("trackinfo_received") -@_yield_from_plugins +@_yield_from_plugins(deprecate_converter=_deprecate_item_candidates_args) def item_candidates(*args, **kwargs) -> Iterator[TrackInfo]: yield from () @notify_info_yielded("albuminfo_received") -@_yield_from_plugins +@_yield_from_plugins() def albums_for_ids(*args, **kwargs) -> Iterator[AlbumInfo]: yield from () @notify_info_yielded("trackinfo_received") -@_yield_from_plugins +@_yield_from_plugins() def tracks_for_ids(*args, **kwargs) -> Iterator[TrackInfo]: yield from () @@ -220,30 +296,46 @@ def track_for_id(self, track_id: str) -> TrackInfo | None: @abc.abstractmethod def candidates( - self, items: Sequence[Item], artist: str, album: str, va_likely: bool + self, source: Source, search_query: SearchQuery | None ) -> Iterable[AlbumInfo]: """Return :py:class:`AlbumInfo` candidates that match the given album. - Used in the autotag functionality to search for albums. + Used in the autotag functionality to search for albums. Two + signatures are supported:: + + candidates(source, search_query) + + candidates(items, artist, album, va_likely) - :param items: List of items in the album - :param artist: Album artist - :param album: Album name - :param va_likely: Whether the album is likely to be by various artists + :param source: Source object containing the items to search for + :param search_query: SearchQuery object containing manually specified search + parameters, or None if no manual search was requested + + .. deprecated:: 2.14.0 + The ``(items, artist, album, va_likely)`` signature will be + removed in 3.0.0. """ raise NotImplementedError @abc.abstractmethod def item_candidates( - self, item: Item, artist: str, title: str + self, source: Source, search_query: SearchQuery | None ) -> Iterable[TrackInfo]: """Return :py:class:`TrackInfo` candidates that match the given track. - Used in the autotag functionality to search for tracks. + Used in the autotag functionality to search for tracks. Two + signatures are supported:: + + item_candidates(source search_query) + + item_candidates(item, artist, title) + + :param item: Item object containing the track to search for + :param search_query: SearchQuery object containing manually specified search + parameters, or None if no manual search was requested - :param item: Track item - :param artist: Track artist - :param title: Track title + .. deprecated:: 2.14.0 + The ``(item, artist, title)`` signature will be removed in 3.0.0. """ raise NotImplementedError diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 729ed5c367..0df6fd1ce9 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -8,6 +8,7 @@ from beets.autotag import ( AlbumMatch, Recommendation, + SearchQuery, TrackMatch, tag_album, tag_item, @@ -511,9 +512,10 @@ def manual_search(session: ImportSession, task: ImportTask) -> Proposal: """ artist = ui.input_("Artist:").strip() name = ui.input_(f"{task.source.type.capitalize()}:").strip() + query = SearchQuery(artist=artist, title=name) method = tag_item if isinstance(task, SingletonImportTask) else tag_album - return method(task.source, artist, name) + return method(task.source, search_query=query) def manual_id(session: ImportSession, task: ImportTask) -> Proposal: