Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions beetsplug/tidal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -128,16 +131,31 @@ 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is zip_longest here for? Also, you can use filter(None, ... instead of checking for None values.

*(
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

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
Expand All @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets make the round robin logic a helper function. Should make the flow here more readable and should allow to dedupe some code.

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
Expand Down
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions test/plugins/test_tidal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading