/
/
/
1"""Tests for Soundcloud artist top tracks and similar tracks."""
2
3from __future__ import annotations
4
5from typing import Any
6
7from aiohttp import ClientError
8
9from music_assistant.providers.soundcloud import SoundcloudMusicProvider
10
11
12def _track_obj(track_id: int, title: str = "Some Track") -> dict[str, Any]:
13 """Build a playable Soundcloud API track object."""
14 return {
15 "id": track_id,
16 "title": title,
17 "duration": 235818,
18 "permalink_url": f"https://soundcloud.com/artist/{track_id}",
19 "user": {"id": 1, "username": "Some Artist", "permalink": "some-artist"},
20 }
21
22
23def _details_for(*track_ids: int) -> Any:
24 """Return a get_track_details stub answering with a track per requested id."""
25
26 async def _get_track_details(track_id: int) -> list[dict[str, Any]]:
27 assert track_id in track_ids
28 return [_track_obj(track_id)]
29
30 return _get_track_details
31
32
33def test_extract_collection_prefers_collection_key(provider: SoundcloudMusicProvider) -> None:
34 """The collection key is used when present."""
35 assert provider._extract_collection({"collection": [{"id": 1}]}) == [{"id": 1}]
36
37
38def test_extract_collection_falls_back_to_items(provider: SoundcloudMusicProvider) -> None:
39 """Some endpoints return items instead of collection."""
40 assert provider._extract_collection({"items": [{"id": 2}]}) == [{"id": 2}]
41
42
43def test_extract_collection_without_usable_data(provider: SoundcloudMusicProvider) -> None:
44 """An empty or missing response yields no collection."""
45 assert provider._extract_collection(None) is None
46 assert provider._extract_collection({}) is None
47
48
49async def test_get_artist(provider: SoundcloudMusicProvider) -> None:
50 """An artist is fetched and parsed by its provider id."""
51 provider._soundcloud.get_user_details.return_value = {
52 "id": 5,
53 "username": "Some Artist",
54 "permalink": "some-artist",
55 }
56
57 get_artist: Any = SoundcloudMusicProvider.get_artist.__wrapped__ # type: ignore[attr-defined]
58 artist = await get_artist(provider, "5")
59
60 provider._soundcloud.get_user_details.assert_awaited_once_with("5")
61 assert artist.item_id == "5"
62 assert artist.name == "Some Artist"
63
64
65async def test_artist_toptracks_from_collection(provider: SoundcloudMusicProvider) -> None:
66 """Top tracks are parsed from the user's tracks response."""
67 provider._soundcloud.get_tracks_from_user.return_value = {"collection": [{"id": 1}, {"id": 2}]}
68 provider._soundcloud.get_track_details = _details_for(1, 2)
69
70 get_artist_toptracks: Any = SoundcloudMusicProvider.get_artist_toptracks.__wrapped__ # type: ignore[attr-defined]
71 tracks = await get_artist_toptracks(provider, "5")
72
73 provider._soundcloud.get_tracks_from_user.assert_awaited_once_with("5", 100)
74 assert [track.item_id for track in tracks] == ["1", "2"]
75
76
77async def test_artist_toptracks_falls_back_to_popular(provider: SoundcloudMusicProvider) -> None:
78 """When the user has no tracks listed, popular tracks are requested instead."""
79 provider._soundcloud.get_tracks_from_user.return_value = {"collection": []}
80 provider._soundcloud.get_popular_tracks_user.return_value = {"items": [{"id": 3}]}
81 provider._soundcloud.get_track_details = _details_for(3)
82
83 get_artist_toptracks: Any = SoundcloudMusicProvider.get_artist_toptracks.__wrapped__ # type: ignore[attr-defined]
84 tracks = await get_artist_toptracks(provider, "5")
85
86 provider._soundcloud.get_popular_tracks_user.assert_awaited_once_with("5", 100)
87 assert [track.item_id for track in tracks] == ["3"]
88
89
90async def test_artist_toptracks_survives_popular_request_failure(
91 provider: SoundcloudMusicProvider,
92) -> None:
93 """A failing popular tracks request results in an empty list, not an error."""
94 provider._soundcloud.get_tracks_from_user.return_value = {"collection": []}
95 provider._soundcloud.get_popular_tracks_user.side_effect = ClientError("boom")
96
97 get_artist_toptracks: Any = SoundcloudMusicProvider.get_artist_toptracks.__wrapped__ # type: ignore[attr-defined]
98 tracks = await get_artist_toptracks(provider, "5")
99
100 assert tracks == []
101
102
103async def test_artist_toptracks_skips_unparsable_track(provider: SoundcloudMusicProvider) -> None:
104 """An unparsable track does not abort the whole listing."""
105 provider._soundcloud.get_tracks_from_user.return_value = {"collection": [{"id": 1}, {"id": 2}]}
106
107 async def _get_track_details(track_id: int) -> list[dict[str, Any]]:
108 if track_id == 1:
109 return [{"id": 1}] # missing title/duration
110 return [_track_obj(track_id)]
111
112 provider._soundcloud.get_track_details = _get_track_details
113
114 get_artist_toptracks: Any = SoundcloudMusicProvider.get_artist_toptracks.__wrapped__ # type: ignore[attr-defined]
115 tracks = await get_artist_toptracks(provider, "5")
116
117 assert [track.item_id for track in tracks] == ["2"]
118
119
120async def test_similar_tracks(provider: SoundcloudMusicProvider) -> None:
121 """Similar tracks are parsed from the recommended response."""
122 provider._soundcloud.get_recommended.return_value = {"collection": [{"id": 7}]}
123 provider._soundcloud.get_track_details = _details_for(7)
124
125 get_similar_tracks: Any = SoundcloudMusicProvider.get_similar_tracks.__wrapped__ # type: ignore[attr-defined]
126 tracks = await get_similar_tracks(provider, "1", 25)
127
128 provider._soundcloud.get_recommended.assert_awaited_once_with("1", 25)
129 assert [track.item_id for track in tracks] == ["7"]
130
131
132async def test_similar_tracks_skips_unparsable_track(provider: SoundcloudMusicProvider) -> None:
133 """An unparsable recommendation does not abort the whole listing."""
134 provider._soundcloud.get_recommended.return_value = {"collection": [{"id": 7}, {"id": 8}]}
135
136 async def _get_track_details(track_id: int) -> list[dict[str, Any]]:
137 if track_id == 7:
138 return [{"id": 7}] # missing title/duration
139 return [_track_obj(track_id)]
140
141 provider._soundcloud.get_track_details = _get_track_details
142
143 get_similar_tracks: Any = SoundcloudMusicProvider.get_similar_tracks.__wrapped__ # type: ignore[attr-defined]
144 tracks = await get_similar_tracks(provider, "1", 25)
145
146 assert [track.item_id for track in tracks] == ["8"]
147
148
149async def test_similar_tracks_without_results(provider: SoundcloudMusicProvider) -> None:
150 """No recommendations yields an empty list."""
151 provider._soundcloud.get_recommended.return_value = {}
152
153 get_similar_tracks: Any = SoundcloudMusicProvider.get_similar_tracks.__wrapped__ # type: ignore[attr-defined]
154 tracks = await get_similar_tracks(provider, "1", 25)
155
156 assert tracks == []
157