/
/
1"""Tests for Soundcloud library listings and recommendations."""
2
3from __future__ import annotations
4
5from collections.abc import AsyncGenerator
6from typing import Any
7
8from music_assistant.providers.soundcloud import SoundcloudMusicProvider
9
10
11def _artist_obj(artist_id: int | None, username: str = "Some Artist") -> dict[str, Any]:
12 """Build a Soundcloud API user object."""
13 return {"id": artist_id, "username": username, "permalink": "some-artist"}
14
15
16def _track_obj(track_id: int, title: str = "Some Track") -> dict[str, Any]:
17 """Build a playable Soundcloud API track object."""
18 return {
19 "id": track_id,
20 "title": title,
21 "duration": 235818,
22 "permalink_url": f"https://soundcloud.com/artist/{track_id}",
23 "user": {"id": 1, "username": "Some Artist", "permalink": "some-artist"},
24 }
25
26
27async def test_library_artists_skip_unparsable(provider: SoundcloudMusicProvider) -> None:
28 """An artist without a valid id is skipped, the rest is returned."""
29 provider._soundcloud.get_following.return_value = {
30 "collection": [_artist_obj(None), _artist_obj(7, "Real Artist")]
31 }
32
33 artists = [artist async for artist in provider.get_library_artists()]
34
35 provider._soundcloud.get_following.assert_awaited_once_with("1")
36 assert [artist.item_id for artist in artists] == ["7"]
37 assert artists[0].name == "Real Artist"
38
39
40async def test_library_playlists(provider: SoundcloudMusicProvider) -> None:
41 """Playlists are resolved to their full object before being parsed."""
42
43 async def _account_playlists() -> AsyncGenerator[dict[str, Any]]:
44 yield {"playlist": {"id": 10}}
45 yield {"not_a_playlist": {}} # unexpected shape, must be skipped
46
47 provider._soundcloud.get_account_playlists = _account_playlists
48 provider._soundcloud.get_playlist_details.return_value = {"id": 10, "title": "Summer Mix"}
49
50 playlists = [playlist async for playlist in provider.get_library_playlists()]
51
52 assert [playlist.item_id for playlist in playlists] == ["10"]
53 assert playlists[0].name == "Summer Mix"
54
55
56async def test_library_playlists_skip_failing_details(provider: SoundcloudMusicProvider) -> None:
57 """A playlist whose details cannot be parsed is skipped."""
58
59 async def _account_playlists() -> AsyncGenerator[dict[str, Any]]:
60 yield {"playlist": {"id": 10}}
61
62 provider._soundcloud.get_account_playlists = _account_playlists
63 provider._soundcloud.get_playlist_details.return_value = {"id": 10} # no title
64
65 playlists = [playlist async for playlist in provider.get_library_playlists()]
66
67 assert playlists == []
68
69
70async def test_library_tracks_skip_unparsable(provider: SoundcloudMusicProvider) -> None:
71 """A track that cannot be parsed is skipped without failing the sync."""
72
73 async def _liked_tracks(_user_id: str) -> AsyncGenerator[dict[str, Any]]:
74 yield {"id": 1} # missing title/duration
75 yield _track_obj(2)
76
77 provider._soundcloud.get_track_details_liked = _liked_tracks
78
79 tracks = [track async for track in provider.get_library_tracks()]
80
81 assert [track.item_id for track in tracks] == ["2"]
82
83
84async def test_recommendation_payload(provider: SoundcloudMusicProvider) -> None:
85 """The payload holds the mixed selections, keeping only system playlists."""
86 provider._soundcloud.get_mixed_selection.return_value = {
87 "collection": [
88 {
89 "id": "mixed-1",
90 "title": "Made for you",
91 "items": {
92 "collection": [
93 {"id": 10, "title": "Summer Mix", "kind": "system-playlist"},
94 {"id": 11, "title": "Not a playlist", "kind": "track"},
95 ]
96 },
97 }
98 ]
99 }
100
101 folders = await provider._fetch_recommendation_payload()
102
103 assert [folder.name for folder in folders] == ["Made for you"]
104 assert [item.item_id for item in folders[0].items] == ["10"]
105 # the feed row is filled on demand, so building the payload must not fetch it
106 provider._soundcloud.get_subscribe_feed.assert_not_awaited()
107
108
109async def test_recommendation_payload_with_empty_selection(
110 provider: SoundcloudMusicProvider,
111) -> None:
112 """A mixed selection without items yields an empty folder instead of failing."""
113 provider._soundcloud.get_mixed_selection.return_value = {
114 "collection": [{"id": "mixed-1", "title": "Made for you"}]
115 }
116
117 folders = await provider._fetch_recommendation_payload()
118
119 assert [folder.name for folder in folders] == ["Made for you"]
120 assert folders[0].items == []
121
122
123async def test_subscribed_feed_tracks(provider: SoundcloudMusicProvider) -> None:
124 """Reposts count as tracks in the feed, other item types are ignored."""
125 provider._soundcloud.get_subscribe_feed.return_value = {
126 "collection": [
127 {"type": "track", "track": _track_obj(1)},
128 {"type": "track-repost", "track": _track_obj(2)},
129 {"type": "playlist", "playlist": {"id": 99}},
130 ]
131 }
132
133 feed_tracks: Any = SoundcloudMusicProvider._get_subscribed_feed_tracks.__wrapped__ # type: ignore[attr-defined]
134 tracks = await feed_tracks(provider)
135
136 assert [track.item_id for track in tracks] == ["1", "2"]
137
138
139async def test_subscribed_feed_skips_unparsable_track(provider: SoundcloudMusicProvider) -> None:
140 """An unplayable track in the feed does not discard the rest of the row."""
141 provider._soundcloud.get_subscribe_feed.return_value = {
142 "collection": [
143 {"type": "track", "track": {"id": 1}}, # missing title/duration
144 {"type": "track", "track": _track_obj(2)},
145 ]
146 }
147
148 feed_tracks: Any = SoundcloudMusicProvider._get_subscribed_feed_tracks.__wrapped__ # type: ignore[attr-defined]
149 tracks = await feed_tracks(provider)
150
151 assert [track.item_id for track in tracks] == ["2"]
152
153
154async def test_subscribed_feed_without_results(provider: SoundcloudMusicProvider) -> None:
155 """An empty subscribed feed yields no tracks."""
156 provider._soundcloud.get_subscribe_feed.return_value = {}
157
158 feed_tracks: Any = SoundcloudMusicProvider._get_subscribed_feed_tracks.__wrapped__ # type: ignore[attr-defined]
159 tracks = await feed_tracks(provider)
160
161 assert tracks == []
162