diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py index 361f5b8955..399d5bc2bc 100644 --- a/beetsplug/tidal/__init__.py +++ b/beetsplug/tidal/__init__.py @@ -74,6 +74,10 @@ def api(self) -> TidalAPI: token_path=self._tokenfile(), ) + @cached_property + def search_limit(self) -> int: + return self.config["search_limit"].get(int) + def _tokenfile(self) -> str: """Return the configured path to the token file in the app directory.""" return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) @@ -114,7 +118,6 @@ def tracks_for_ids(self, ids: Iterable[str]) -> Iterable[TrackInfo | None]: def candidates( self, items: Sequence[Item], artist: str, album: str, va_likely: bool ) -> Iterable[AlbumInfo]: - candidates: list[AlbumInfo] = [] # Tidal allows to lookup via isrc and barcode (nice!) # We just return early here as a lookup via isrc should # return a 100% match @@ -128,8 +131,24 @@ def candidates( ): return candidates - for query in self._album_queries(items): - candidates += self.search_albums_by_query(query) + candidates = list( + itertools.islice( + ( + candidate + for candidate in itertools.chain.from_iterable( + itertools.zip_longest( + *( + self.search_albums_by_query(query) + for query in self._album_queries(items) + ), + fillvalue=None, + ) + ) + if candidate is not None + ), + self.search_limit, + ) + ) log.debug("Found {0} candidates", len(candidates)) return candidates @@ -137,7 +156,6 @@ def candidates( def item_candidates( self, item: Item, artist: str, title: str ) -> Iterable[TrackInfo]: - candidates: list[TrackInfo] = [] # Tidal allows to lookup via isrc and barcode (nice!) # We just return early here as a lookup via isrc should # return a 100% match @@ -147,8 +165,24 @@ def item_candidates( ): return candidates - for query in self._item_queries(item): - candidates += self.search_tracks_by_query(query) + candidates = list( + itertools.islice( + ( + candidate + for candidate in itertools.chain.from_iterable( + itertools.zip_longest( + *( + self.search_tracks_by_query(query) + for query in self._item_queries(item) + ), + fillvalue=None, + ) + ) + if candidate is not None + ), + self.search_limit, + ) + ) log.debug("Found {0} candidates", len(candidates)) return candidates diff --git a/docs/changelog.rst b/docs/changelog.rst index 2609ce8fa9..6dc7986250 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,6 +34,8 @@ Bug fixes example a multi-disc album whose cover lives in the album root rather than a per-disc directory); the missing art is skipped instead. :bug:`4692` - :doc:`plugins/tidal`: Normalize Tidal album types to lowercase. +- :doc:`plugins/tidal`: ``candidates()`` and ``item_candidates()`` now respect + the ``search_limit`` config option. :bug:`6770` .. For plugin developers diff --git a/test/plugins/test_tidal.py b/test/plugins/test_tidal.py index b76693e303..e85efcbb63 100644 --- a/test/plugins/test_tidal.py +++ b/test/plugins/test_tidal.py @@ -551,6 +551,153 @@ def test_candidates_with_query_fallback(self): assert candidates[0].album == "Query Album" +class TestSearchLimit(TidalPluginTest): + """Tests for search_limit config option.""" + + def test_candidates_respects_search_limit(self): + """Test that candidates caps results to search_limit.""" + items = [Item(title="My Song", artist="My Artist", album="My Album")] + + self.tidal.config["search_limit"] = 1 + + # Single query returns 2 album IDs; only the first should be used. + self.tidal.api.search_results = Mock( + return_value={ + "data": { + "relationships": { + "albums": { + "data": [ + {"id": "1", "type": "albums"}, + {"id": "2", "type": "albums"}, + ] + } + } + } + } + ) + + track1 = _make_track("101", "Track 1", "PT3M", "ISRC001", ["1001"]) + track2 = _make_track("201", "Track 2", "PT3M", "ISRC002", ["1001"]) + album1, track_lookup1, artist_lookup1 = _make_album( + "1", "Album One", [track1], ["1001"] + ) + album2, track_lookup2, artist_lookup2 = _make_album( + "2", "Album Two", [track2], ["1001"] + ) + self.tidal.api.get_albums = Mock( + return_value={ + "data": [album1, album2], + "included": [ + *artist_lookup1.values(), + *artist_lookup2.values(), + *track_lookup1.values(), + *track_lookup2.values(), + ], + } + ) + + candidates = list( + self.tidal.candidates(items, "My Artist", "My Album", False) + ) + + assert len(candidates) == 1 + assert candidates[0].album == "Album One" + + def test_candidates_round_robin_across_queries(self): + """Test that candidates interleaves results across multiple queries.""" + # Two artists produce two queries via _album_queries cartesian product. + items = [ + Item(title="Song A", artist="Artist A", album="Shared Album"), + Item(title="Song B", artist="Artist B", album="Shared Album"), + ] + + self.tidal.config["search_limit"] = 2 + + track_a = _make_track("101", "Track A", "PT3M", "ISRC001", ["1001"]) + track_b = _make_track("201", "Track B", "PT3M", "ISRC002", ["1002"]) + album_a, track_lookup_a, artist_lookup_a = _make_album( + "10", "Album A", [track_a], ["1001"] + ) + album_b, track_lookup_b, artist_lookup_b = _make_album( + "20", "Album B", [track_b], ["1002"] + ) + + # Each query returns one album ID from its respective artist. + self.tidal.api.search_results = Mock( + side_effect=[ + { + "data": { + "relationships": { + "albums": {"data": [{"id": "10", "type": "albums"}]} + } + } + }, + { + "data": { + "relationships": { + "albums": {"data": [{"id": "20", "type": "albums"}]} + } + } + }, + ] + ) + self.tidal.api.get_albums = Mock( + side_effect=[ + { + "data": [album_a], + "included": [ + *artist_lookup_a.values(), + *track_lookup_a.values(), + ], + }, + { + "data": [album_b], + "included": [ + *artist_lookup_b.values(), + *track_lookup_b.values(), + ], + }, + ] + ) + + candidates = list( + self.tidal.candidates(items, "Artist A", "Shared Album", False) + ) + + assert len(candidates) == 2 + album_names = {c.album for c in candidates} + assert album_names == {"Album A", "Album B"} + + def test_item_candidates_respects_search_limit(self): + """Test that item_candidates caps results to search_limit.""" + item = Item(title="Query Song", artist="Query Artist") + + self.tidal.config["search_limit"] = 1 + + # _item_queries yields two queries: title, then "artist title". + # Each returns one track; only the first query's result should be kept. + self.tidal.api.search_results = Mock( + return_value={ + "data": { + "relationships": { + "tracks": {"data": [{"id": "1", "type": "tracks"}]} + } + }, + "included": [ + _make_track("1", "Track One", "PT3M", "ISRC001", ["1001"]), + _make_artist("1001", "Query Artist"), + ], + } + ) + + results = list( + self.tidal.item_candidates(item, "Query Artist", "Query Song") + ) + + assert len(results) == 1 + assert results[0].title == "Track One" + + class TestItemCandidates(TidalPluginTest): """Tests for item_candidates method."""