From b9e56fcc531bf4605a35fba3c951f88809bfb942 Mon Sep 17 00:00:00 2001 From: Krishna Date: Fri, 26 Jun 2026 22:42:58 -0700 Subject: [PATCH 1/6] tidal: respect search_limit config option in candidates() TidalPlugin.candidates() and item_candidates() called search_albums_by_query() and search_tracks_by_query() without passing any limit, ignoring the search_limit config value inherited from MetadataSourcePlugin. Add an optional limit parameter to both query methods and slice the result list before fetching full records, matching the pattern used by other direct-implementation plugins. Fixes beetbox/beets#6770 Co-Authored-By: Claude Sonnet 4.6 --- beetsplug/tidal/__init__.py | 21 ++++++--- test/plugins/test_tidal.py | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py index 361f5b8955..4909771d13 100644 --- a/beetsplug/tidal/__init__.py +++ b/beetsplug/tidal/__init__.py @@ -128,8 +128,9 @@ def candidates( ): return candidates + search_limit = self.config["search_limit"].get(int) for query in self._album_queries(items): - candidates += self.search_albums_by_query(query) + candidates += self.search_albums_by_query(query, limit=search_limit) log.debug("Found {0} candidates", len(candidates)) return candidates @@ -147,8 +148,9 @@ def item_candidates( ): return candidates + search_limit = self.config["search_limit"].get(int) for query in self._item_queries(item): - candidates += self.search_tracks_by_query(query) + candidates += self.search_tracks_by_query(query, limit=search_limit) log.debug("Found {0} candidates", len(candidates)) return candidates @@ -171,7 +173,9 @@ def _album_queries(items: Sequence[Item]) -> Iterable[str]: for album, artist in itertools.product(album_names, artist_names): yield f"{artist} {album}" - def search_tracks_by_query(self, query: str) -> Iterable[TrackInfo]: + def search_tracks_by_query( + self, query: str, limit: int | None = None + ) -> Iterable[TrackInfo]: """Search for tracks given a string query.""" search_doc = self.api.search_results(query, include=["tracks.artists"]) track_by_id: dict[str, TidalTrack] = { @@ -184,7 +188,10 @@ def search_tracks_by_query(self, query: str) -> Iterable[TrackInfo]: for item in search_doc.get("included", []) if item["type"] == "artists" } - for track_rel in search_doc["data"]["relationships"]["tracks"]["data"]: + track_rels = search_doc["data"]["relationships"]["tracks"]["data"] + if limit is not None: + track_rels = track_rels[:limit] + for track_rel in track_rels: if track := track_by_id.get(track_rel["id"]): yield self._get_track_info(track, artist_by_id=artist_by_id) else: @@ -192,7 +199,9 @@ def search_tracks_by_query(self, query: str) -> Iterable[TrackInfo]: "Track with id {0} not found in lookup", track_rel["id"] ) - def search_albums_by_query(self, query: str) -> Iterable[AlbumInfo]: + def search_albums_by_query( + self, query: str, limit: int | None = None + ) -> Iterable[AlbumInfo]: """Search for album given a string query.""" search_doc = self.api.search_results( query, @@ -207,6 +216,8 @@ def search_albums_by_query(self, query: str) -> Iterable[AlbumInfo]: "data" ] ] + if limit is not None: + album_ids = album_ids[:limit] yield from filter(None, self.search_albums_by_ids(tidal_ids=album_ids)) @overload diff --git a/test/plugins/test_tidal.py b/test/plugins/test_tidal.py index b76693e303..4ceaa56d96 100644 --- a/test/plugins/test_tidal.py +++ b/test/plugins/test_tidal.py @@ -551,6 +551,92 @@ 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 returns at most search_limit results.""" + items = [Item(title="My Song", artist="My Artist", album="My Album")] + + self.tidal.config["search_limit"] = 1 + + 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_item_candidates_respects_search_limit(self): + """Test that item_candidates returns at most search_limit results.""" + # No artist so _item_queries yields only one query (title only) + item = Item(title="Query Song") + + self.tidal.config["search_limit"] = 1 + + self.tidal.api.search_results = Mock( + return_value={ + "data": { + "relationships": { + "tracks": { + "data": [ + {"id": "1", "type": "tracks"}, + {"id": "2", "type": "tracks"}, + ] + } + } + }, + "included": [ + _make_track("1", "Track One", "PT3M", "ISRC001", ["1001"]), + _make_track("2", "Track Two", "PT3M", "ISRC002", ["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.""" From 0e80c5d1ab1998bdd31d9e00e54728fc57103885 Mon Sep 17 00:00:00 2001 From: Krishna Date: Fri, 26 Jun 2026 22:47:48 -0700 Subject: [PATCH 2/6] docs: add changelog entry for tidal search_limit fix (#6770) Co-Authored-By: Claude Sonnet 4.6 --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) 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 From 7613c3b6185482a128b7bca6e1cff7a1c4497854 Mon Sep 17 00:00:00 2001 From: Krishna Date: Sat, 27 Jun 2026 09:34:46 -0700 Subject: [PATCH 3/6] tidal: apply search_limit via round-robin early-break in candidates() Instead of drilling a limit parameter into search_albums_by_query and search_tracks_by_query, handle the cap entirely inside candidates() and item_candidates() by consuming each query's iterator in a round-robin loop and stopping as soon as search_limit results have been collected. This keeps the query functions pure and correctly interleaves results across multiple queries (e.g. multiple artist/album combinations from _album_queries) rather than exhausting one query before moving to the next. Co-Authored-By: Claude Sonnet 4.6 --- beetsplug/tidal/__init__.py | 45 +++++++++++++-------- test/plugins/test_tidal.py | 81 ++++++++++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 25 deletions(-) diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py index 4909771d13..b7798e3730 100644 --- a/beetsplug/tidal/__init__.py +++ b/beetsplug/tidal/__init__.py @@ -129,8 +129,19 @@ def candidates( return candidates search_limit = self.config["search_limit"].get(int) - for query in self._album_queries(items): - candidates += self.search_albums_by_query(query, limit=search_limit) + query_iters = [ + self.search_albums_by_query(q) for q in self._album_queries(items) + ] + while query_iters and len(candidates) < search_limit: + exhausted = [] + for it in query_iters: + if len(candidates) >= search_limit: + break + try: + candidates.append(next(it)) + except StopIteration: + exhausted.append(it) + query_iters = [it for it in query_iters if it not in exhausted] log.debug("Found {0} candidates", len(candidates)) return candidates @@ -149,8 +160,19 @@ def item_candidates( return candidates search_limit = self.config["search_limit"].get(int) - for query in self._item_queries(item): - candidates += self.search_tracks_by_query(query, limit=search_limit) + query_iters = [ + self.search_tracks_by_query(q) for q in self._item_queries(item) + ] + while query_iters and len(candidates) < search_limit: + exhausted = [] + for it in query_iters: + if len(candidates) >= search_limit: + break + try: + candidates.append(next(it)) + except StopIteration: + exhausted.append(it) + query_iters = [it for it in query_iters if it not in exhausted] log.debug("Found {0} candidates", len(candidates)) return candidates @@ -173,9 +195,7 @@ def _album_queries(items: Sequence[Item]) -> Iterable[str]: for album, artist in itertools.product(album_names, artist_names): yield f"{artist} {album}" - def search_tracks_by_query( - self, query: str, limit: int | None = None - ) -> Iterable[TrackInfo]: + def search_tracks_by_query(self, query: str) -> Iterable[TrackInfo]: """Search for tracks given a string query.""" search_doc = self.api.search_results(query, include=["tracks.artists"]) track_by_id: dict[str, TidalTrack] = { @@ -188,10 +208,7 @@ def search_tracks_by_query( for item in search_doc.get("included", []) if item["type"] == "artists" } - track_rels = search_doc["data"]["relationships"]["tracks"]["data"] - if limit is not None: - track_rels = track_rels[:limit] - for track_rel in track_rels: + for track_rel in search_doc["data"]["relationships"]["tracks"]["data"]: if track := track_by_id.get(track_rel["id"]): yield self._get_track_info(track, artist_by_id=artist_by_id) else: @@ -199,9 +216,7 @@ def search_tracks_by_query( "Track with id {0} not found in lookup", track_rel["id"] ) - def search_albums_by_query( - self, query: str, limit: int | None = None - ) -> Iterable[AlbumInfo]: + def search_albums_by_query(self, query: str) -> Iterable[AlbumInfo]: """Search for album given a string query.""" search_doc = self.api.search_results( query, @@ -216,8 +231,6 @@ def search_albums_by_query( "data" ] ] - if limit is not None: - album_ids = album_ids[:limit] yield from filter(None, self.search_albums_by_ids(tidal_ids=album_ids)) @overload diff --git a/test/plugins/test_tidal.py b/test/plugins/test_tidal.py index 4ceaa56d96..53363e8529 100644 --- a/test/plugins/test_tidal.py +++ b/test/plugins/test_tidal.py @@ -555,11 +555,12 @@ class TestSearchLimit(TidalPluginTest): """Tests for search_limit config option.""" def test_candidates_respects_search_limit(self): - """Test that candidates returns at most search_limit results.""" + """Test that candidates caps results to search_limit via early iteration stop.""" 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": { @@ -602,28 +603,90 @@ def test_candidates_respects_search_limit(self): 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 returns at most search_limit results.""" - # No artist so _item_queries yields only one query (title only) - item = Item(title="Query Song") + """Test that item_candidates caps results to search_limit via early iteration stop.""" + 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"}, - {"id": "2", "type": "tracks"}, - ] + "data": [{"id": "1", "type": "tracks"}] } } }, "included": [ _make_track("1", "Track One", "PT3M", "ISRC001", ["1001"]), - _make_track("2", "Track Two", "PT3M", "ISRC002", ["1001"]), _make_artist("1001", "Query Artist"), ], } From 4c0779df89b7b764bed1ec0316290136381a4627 Mon Sep 17 00:00:00 2001 From: Krishna Date: Sat, 27 Jun 2026 09:46:35 -0700 Subject: [PATCH 4/6] tidal: fix lint, formatting, and mypy errors - Wrap query iterables with iter() so next() satisfies mypy's SupportsNext constraint - Shorten two docstrings that exceeded the 88-char line limit - Reformat test file to satisfy ruff Co-Authored-By: Claude Sonnet 4.6 --- beetsplug/tidal/__init__.py | 6 ++++-- test/plugins/test_tidal.py | 8 +++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py index b7798e3730..bc897c7146 100644 --- a/beetsplug/tidal/__init__.py +++ b/beetsplug/tidal/__init__.py @@ -130,7 +130,8 @@ def candidates( search_limit = self.config["search_limit"].get(int) query_iters = [ - self.search_albums_by_query(q) for q in self._album_queries(items) + iter(self.search_albums_by_query(q)) + for q in self._album_queries(items) ] while query_iters and len(candidates) < search_limit: exhausted = [] @@ -161,7 +162,8 @@ def item_candidates( search_limit = self.config["search_limit"].get(int) query_iters = [ - self.search_tracks_by_query(q) for q in self._item_queries(item) + iter(self.search_tracks_by_query(q)) + for q in self._item_queries(item) ] while query_iters and len(candidates) < search_limit: exhausted = [] diff --git a/test/plugins/test_tidal.py b/test/plugins/test_tidal.py index 53363e8529..e85efcbb63 100644 --- a/test/plugins/test_tidal.py +++ b/test/plugins/test_tidal.py @@ -555,7 +555,7 @@ class TestSearchLimit(TidalPluginTest): """Tests for search_limit config option.""" def test_candidates_respects_search_limit(self): - """Test that candidates caps results to search_limit via early iteration stop.""" + """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 @@ -669,7 +669,7 @@ def test_candidates_round_robin_across_queries(self): assert album_names == {"Album A", "Album B"} def test_item_candidates_respects_search_limit(self): - """Test that item_candidates caps results to search_limit via early iteration stop.""" + """Test that item_candidates caps results to search_limit.""" item = Item(title="Query Song", artist="Query Artist") self.tidal.config["search_limit"] = 1 @@ -680,9 +680,7 @@ def test_item_candidates_respects_search_limit(self): return_value={ "data": { "relationships": { - "tracks": { - "data": [{"id": "1", "type": "tracks"}] - } + "tracks": {"data": [{"id": "1", "type": "tracks"}]} } }, "included": [ From a69276ff501c6725af1b1e177901d87b0df0c98b Mon Sep 17 00:00:00 2001 From: Krishna Date: Sun, 28 Jun 2026 09:06:46 -0700 Subject: [PATCH 5/6] tidal: simplify search_limit handling with itertools Use cached_property for search_limit and replace manual round-robin loops with itertools.chain.from_iterable, zip_longest, and islice. --- beetsplug/tidal/__init__.py | 74 +++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py index bc897c7146..10552ce688 100644 --- a/beetsplug/tidal/__init__.py +++ b/beetsplug/tidal/__init__.py @@ -39,6 +39,8 @@ log = getLogger("beets.tidal") +_EXHAUSTED = object() + class TidalPlugin(MetadataSourcePlugin): item_types: ClassVar[dict[str, types.Type]] = { @@ -74,6 +76,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 +120,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,21 +133,24 @@ def candidates( ): return candidates - search_limit = self.config["search_limit"].get(int) - query_iters = [ - iter(self.search_albums_by_query(q)) - for q in self._album_queries(items) - ] - while query_iters and len(candidates) < search_limit: - exhausted = [] - for it in query_iters: - if len(candidates) >= search_limit: - break - try: - candidates.append(next(it)) - except StopIteration: - exhausted.append(it) - query_iters = [it for it in query_iters if it not in exhausted] + 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=_EXHAUSTED, + ) + ) + if candidate is not _EXHAUSTED + ), + self.search_limit, + ) + ) log.debug("Found {0} candidates", len(candidates)) return candidates @@ -150,7 +158,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 @@ -160,21 +167,24 @@ def item_candidates( ): return candidates - search_limit = self.config["search_limit"].get(int) - query_iters = [ - iter(self.search_tracks_by_query(q)) - for q in self._item_queries(item) - ] - while query_iters and len(candidates) < search_limit: - exhausted = [] - for it in query_iters: - if len(candidates) >= search_limit: - break - try: - candidates.append(next(it)) - except StopIteration: - exhausted.append(it) - query_iters = [it for it in query_iters if it not in exhausted] + 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=_EXHAUSTED, + ) + ) + if candidate is not _EXHAUSTED + ), + self.search_limit, + ) + ) log.debug("Found {0} candidates", len(candidates)) return candidates From c33a52013b953522999ccfb869396efd5044329f Mon Sep 17 00:00:00 2001 From: Krishna Date: Sun, 28 Jun 2026 09:13:57 -0700 Subject: [PATCH 6/6] tidal: fix mypy errors in interleaved search results Use None as the zip_longest fill value so mypy can narrow candidate types instead of inferring object from an object() sentinel. --- beetsplug/tidal/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py index 10552ce688..399d5bc2bc 100644 --- a/beetsplug/tidal/__init__.py +++ b/beetsplug/tidal/__init__.py @@ -39,8 +39,6 @@ log = getLogger("beets.tidal") -_EXHAUSTED = object() - class TidalPlugin(MetadataSourcePlugin): item_types: ClassVar[dict[str, types.Type]] = { @@ -143,10 +141,10 @@ def candidates( self.search_albums_by_query(query) for query in self._album_queries(items) ), - fillvalue=_EXHAUSTED, + fillvalue=None, ) ) - if candidate is not _EXHAUSTED + if candidate is not None ), self.search_limit, ) @@ -177,10 +175,10 @@ def item_candidates( self.search_tracks_by_query(query) for query in self._item_queries(item) ), - fillvalue=_EXHAUSTED, + fillvalue=None, ) ) - if candidate is not _EXHAUSTED + if candidate is not None ), self.search_limit, )