diff --git a/beets/autotag/match.py b/beets/autotag/match.py index d0f3fd1346..59c9908b03 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -236,11 +236,30 @@ def _add_candidate( ) +def _parse_search_terms_with_fallbacks( + *pairs: tuple[str | None, str], +) -> tuple[str, ...]: + """Given pairs of (search term, fallback), return a tuple of + search terms. If **all** search terms are empty, returns the fallback. Otherwise, + return the search terms even if some are empty. + + Examples: + (("", "F1"), ("B", "F2")) -> ("", "B") + (("", "F1"), ("", "F2")) -> ("F1", "F2") + (("A", "F1"), (None, "F2")) -> ("A", "") + ((None, "F1"), (None, "F2")) -> ("F1", "F2") + """ + if any(term for term, _ in pairs): + return tuple(term or "" for term, _ in pairs) + else: + return tuple(fallback for _, fallback in pairs) + + def tag_album( items, - search_artist: str | None = None, - search_album: str | None = None, - search_ids: list[str] = [], + search_artist: str = "", + search_album: str = "", + search_ids: Sequence[str] = (), ) -> tuple[str, str, Proposal]: """Return a tuple of the current artist name, the current album name, and a `Proposal` containing `AlbumMatch` candidates. @@ -269,16 +288,15 @@ def tag_album( candidates: dict[Any, AlbumMatch] = {} # Search by explicit ID. - if search_ids: - for search_id in search_ids: - log.debug("Searching for album ID: {}", search_id) - if info := metadata_plugins.album_for_id(search_id): - _add_candidate(items, candidates, info) - if opt_candidate := candidates.get(info.album_id): - plugins.send("album_matched", match=opt_candidate) + for search_id in search_ids: + log.debug("Searching for album ID: {}", search_id) + if info := metadata_plugins.album_for_id(search_id): + _add_candidate(items, candidates, info) + if opt_candidate := candidates.get(info.album_id): + plugins.send("album_matched", match=opt_candidate) # Use existing metadata or text search. - else: + if not search_ids: # Try search based on current ID. if info := match_by_id(items): _add_candidate(items, candidates, info) @@ -299,23 +317,24 @@ def tag_album( Proposal(list(candidates.values()), rec), ) - # Search terms. - if not (search_artist and search_album): - # No explicit search terms -- use current metadata. - search_artist, search_album = cur_artist, cur_album - log.debug("Search terms: {} - {}", search_artist, search_album) + # Manually provided search terms or fallbacks. + _search_artist, _search_album = _parse_search_terms_with_fallbacks( + (search_artist, cur_artist), + (search_album, cur_album), + ) + log.debug("Search terms: {} - {}", _search_artist, _search_album) # Is this album likely to be a "various artist" release? va_likely = ( (not consensus["artist"]) - or (search_artist.lower() in VA_ARTISTS) + or (_search_artist.lower() in VA_ARTISTS) or any(item.comp for item in items) ) log.debug("Album might be VA: {}", va_likely) # Get the results from the data sources. for matched_candidate in metadata_plugins.candidates( - items, search_artist, search_album, va_likely + items, _search_artist, _search_album, va_likely ): _add_candidate(items, candidates, matched_candidate) if opt_candidate := candidates.get(matched_candidate.album_id): @@ -329,10 +348,10 @@ def tag_album( def tag_item( - item, - search_artist: str | None = None, - search_title: str | None = None, - search_ids: list[str] | None = None, + item: Item, + search_artist: str = "", + search_title: str = "", + search_ids: Sequence[str] = (), ) -> Proposal: """Find metadata for a single track. Return a `Proposal` consisting of `TrackMatch` objects. @@ -371,14 +390,18 @@ def tag_item( else: return Proposal([], Recommendation.none) - # Search terms. - search_artist = search_artist or item.artist - search_title = search_title or item.title - log.debug("Item search terms: {} - {}", search_artist, search_title) + # Manually provided search terms or fallbacks. + _search_artist, _search_title = _parse_search_terms_with_fallbacks( + (search_artist, item.artist), + (search_title, item.title), + ) + log.debug("Item search terms: {} - {}", _search_artist, _search_title) # Get and evaluate candidate metadata. for track_info in metadata_plugins.item_candidates( - item, search_artist, search_title + item, + _search_artist, + _search_title, ): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index f42e8f690a..d749f6c7b2 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -35,17 +35,21 @@ def find_metadata_source_plugins() -> list[MetadataSourcePlugin]: @notify_info_yielded("albuminfo_received") -def candidates(*args, **kwargs) -> Iterable[AlbumInfo]: +def candidates( + items: Sequence[Item], artist: str, album: str, va_likely: bool +) -> Iterable[AlbumInfo]: """Return matching album candidates from all metadata source plugins.""" for plugin in find_metadata_source_plugins(): - yield from plugin.candidates(*args, **kwargs) + yield from plugin.candidates( + items=items, artist=artist, album=album, va_likely=va_likely + ) @notify_info_yielded("trackinfo_received") -def item_candidates(*args, **kwargs) -> Iterable[TrackInfo]: - """Return matching track candidates fromm all metadata source plugins.""" +def item_candidates(item: Item, artist: str, title: str) -> Iterable[TrackInfo]: + """Return matching track candidates from all metadata source plugins.""" for plugin in find_metadata_source_plugins(): - yield from plugin.item_candidates(*args, **kwargs) + yield from plugin.item_candidates(item=item, artist=artist, title=title) def album_for_id(_id: str) -> AlbumInfo | None: @@ -152,20 +156,27 @@ def candidates( :param artist: Album artist :param album: Album name :param va_likely: Whether the album is likely to be by various artists + + Note that `artist` and `album` may contain additional user-supplied search terms + intended to refine the query. When relevant, prefer these values over + metadata extracted from item directly. """ raise NotImplementedError @abc.abstractmethod def item_candidates( - self, item: Item, artist: str, title: str + self, + item: Item, + artist: str, + title: str, ) -> Iterable[TrackInfo]: """Return :py:class:`TrackInfo` candidates that match the given track. Used in the autotag functionality to search for tracks. - :param item: Track item - :param artist: Track artist - :param title: Track title + Note that `artist` and `title` may contain additional user-supplied search terms + intended to refine the query. When relevant, prefer these values over + metadata extracted from item directly. """ raise NotImplementedError diff --git a/docs/changelog.rst b/docs/changelog.rst index c5a0dab539..337fb24f81 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,6 +66,11 @@ Other changes: - Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into multiple modules within the ``beets/ui/commands`` directory for better maintainability. +- Standardized ``search_*`` parameter handling in autotag matchers. Manual album + and singleton searches now behave consistently: when a user does not specify a + search query in the prompt, the system defaults to using the corresponding + value from the metadata. This was already the case for albums but not for + singletons. 2.5.1 (October 14, 2025) ------------------------ diff --git a/test/autotag/test_match.py b/test/autotag/test_match.py new file mode 100644 index 0000000000..b45e21d5b9 --- /dev/null +++ b/test/autotag/test_match.py @@ -0,0 +1,40 @@ +import pytest + +from beets.autotag.match import _parse_search_terms_with_fallbacks + + +class TestSearchTermHandling: + @pytest.mark.parametrize( + "input_pairs, expected", + [ + ( + (("A", "F1"), ("B", "F2")), + ("A", "B"), + ), + ( + (("", "F1"), ("B", "F2")), + ("", "B"), + ), + ( + (("", "F1"), ("", "F2")), + ("F1", "F2"), + ), + ( + (("A", "F1"), (None, "F2")), + ("A", ""), + ), + ( + ((None, "F1"), (None, "F2")), + ("F1", "F2"), + ), + ], + ) + def test_search_parsing(self, input_pairs, expected): + result = _parse_search_terms_with_fallbacks(*input_pairs) + assert result == expected + + # Should also apply for the reversed order of inputs + reversed_pairs = tuple(reversed(input_pairs)) + reversed_expected = tuple(reversed(expected)) + reversed_result = _parse_search_terms_with_fallbacks(*reversed_pairs) + assert reversed_result == reversed_expected