/
/
/
1"""Tests for Soundcloud playlist parsing and playlist track listings."""
2
3from __future__ import annotations
4
5from typing import Any
6
7from music_assistant.providers.soundcloud import SoundcloudMusicProvider
8
9
10def _track_obj(track_id: int, title: str) -> dict[str, Any]:
11 """Build a playable Soundcloud API track object."""
12 return {
13 "id": track_id,
14 "title": title,
15 "duration": 235818,
16 "permalink_url": f"https://soundcloud.com/artist/{track_id}",
17 "user": {"id": 1, "username": "Some Artist", "permalink": "some-artist"},
18 }
19
20
21def _playlist_obj(**extra: Any) -> dict[str, Any]:
22 """Build a Soundcloud API playlist object."""
23 return {"id": 10, "title": "Summer Mix", **extra}
24
25
26async def test_parse_playlist_basics(provider: SoundcloudMusicProvider) -> None:
27 """A playlist is parsed with its provider mapping and is not editable."""
28 playlist = await provider._parse_playlist(_playlist_obj())
29
30 assert playlist.item_id == "10"
31 assert playlist.name == "Summer Mix"
32 assert playlist.provider == "soundcloud"
33 assert playlist.is_editable is False
34 mapping = next(iter(playlist.provider_mappings))
35 assert mapping.item_id == "10"
36 assert mapping.provider_instance == provider.instance_id
37
38
39async def test_parse_playlist_strips_related_tracks_prefix(
40 provider: SoundcloudMusicProvider,
41) -> None:
42 """The 'Related tracks: ' prefix Soundcloud adds is removed from the name."""
43 playlist = await provider._parse_playlist(_playlist_obj(title="Related tracks: Summer Mix"))
44
45 assert playlist.name == "Summer Mix"
46
47
48async def test_parse_playlist_metadata(provider: SoundcloudMusicProvider) -> None:
49 """Description, artwork, genre and tags are carried over into the metadata."""
50 playlist = await provider._parse_playlist(
51 _playlist_obj(
52 description="Sunny tunes",
53 artwork_url="https://i1.sndcdn.com/artworks-abc-large.jpg",
54 genre="House",
55 tag_list="house dance",
56 )
57 )
58
59 assert playlist.metadata.description == "Sunny tunes"
60 assert playlist.metadata.genres == {"House"}
61 assert playlist.metadata.style == "house dance"
62 # artwork is upgraded to the high resolution variant
63 assert playlist.metadata.images is not None
64 assert playlist.metadata.images[0].path.endswith("artworks-abc-t500x500.jpg")
65 assert playlist.metadata.images[0].remotely_accessible is True
66
67
68async def test_get_playlist(provider: SoundcloudMusicProvider) -> None:
69 """A playlist is fetched and parsed by its provider id."""
70 provider._soundcloud.get_playlist_details.return_value = _playlist_obj()
71
72 get_playlist: Any = SoundcloudMusicProvider.get_playlist.__wrapped__ # type: ignore[attr-defined]
73 playlist = await get_playlist(provider, "10")
74
75 provider._soundcloud.get_playlist_details.assert_awaited_once_with("10")
76 assert playlist.item_id == "10"
77 assert playlist.name == "Summer Mix"
78
79
80async def test_playlist_tracks_returns_parsed_tracks(provider: SoundcloudMusicProvider) -> None:
81 """Tracks embedded in the playlist response are parsed in order."""
82 provider._soundcloud.get_playlist_details.return_value = _playlist_obj(
83 tracks=[_track_obj(1, "First"), _track_obj(2, "Second")]
84 )
85
86 get_playlist_tracks: Any = SoundcloudMusicProvider.get_playlist_tracks.__wrapped__ # type: ignore[attr-defined]
87 tracks = await get_playlist_tracks(provider, "10")
88
89 assert [track.item_id for track in tracks] == ["1", "2"]
90 assert [track.position for track in tracks] == [1, 2]
91
92
93async def test_playlist_tracks_fetches_details_for_partial_entries(
94 provider: SoundcloudMusicProvider,
95) -> None:
96 """Entries without a title are resolved through a track details call."""
97 provider._soundcloud.get_playlist_details.return_value = _playlist_obj(
98 tracks=[{"id": 5, "kind": "track", "monetization_model": "NOT_APPLICABLE"}]
99 )
100 provider._soundcloud.get_track_details.return_value = [_track_obj(5, "Resolved")]
101
102 get_playlist_tracks: Any = SoundcloudMusicProvider.get_playlist_tracks.__wrapped__ # type: ignore[attr-defined]
103 tracks = await get_playlist_tracks(provider, "10")
104
105 provider._soundcloud.get_track_details.assert_awaited_once_with(5)
106 assert [track.name for track in tracks] == ["Resolved"]
107
108
109async def test_playlist_tracks_skips_unparsable_entry(provider: SoundcloudMusicProvider) -> None:
110 """A broken entry is skipped without dropping the rest of the playlist."""
111 provider._soundcloud.get_playlist_details.return_value = _playlist_obj(
112 tracks=[{"title": "Broken"}, _track_obj(2, "Second")]
113 )
114
115 get_playlist_tracks: Any = SoundcloudMusicProvider.get_playlist_tracks.__wrapped__ # type: ignore[attr-defined]
116 tracks = await get_playlist_tracks(provider, "10")
117
118 assert [track.item_id for track in tracks] == ["2"]
119
120
121async def test_playlist_tracks_without_tracks_key(provider: SoundcloudMusicProvider) -> None:
122 """A playlist response without tracks yields an empty list."""
123 provider._soundcloud.get_playlist_details.return_value = _playlist_obj()
124
125 get_playlist_tracks: Any = SoundcloudMusicProvider.get_playlist_tracks.__wrapped__ # type: ignore[attr-defined]
126 tracks = await get_playlist_tracks(provider, "10")
127
128 assert tracks == []
129
130
131async def test_playlist_tracks_second_page_is_empty(provider: SoundcloudMusicProvider) -> None:
132 """Soundcloud has no paging for playlist tracks, so later pages are empty."""
133 get_playlist_tracks: Any = SoundcloudMusicProvider.get_playlist_tracks.__wrapped__ # type: ignore[attr-defined]
134 tracks = await get_playlist_tracks(provider, "10", 1)
135
136 assert tracks == []
137 provider._soundcloud.get_playlist_details.assert_not_awaited()
138
139
140async def test_playlist_tracks_uses_system_playlist_endpoint(
141 provider: SoundcloudMusicProvider,
142) -> None:
143 """System playlists are fetched through their own endpoint."""
144 playlist_id = "soundcloud:system-playlists:artist-stations:1"
145 provider._soundcloud.get_system_playlist_details.return_value = _playlist_obj(
146 tracks=[_track_obj(1, "First")]
147 )
148
149 get_playlist_tracks: Any = SoundcloudMusicProvider.get_playlist_tracks.__wrapped__ # type: ignore[attr-defined]
150 tracks = await get_playlist_tracks(provider, playlist_id)
151
152 provider._soundcloud.get_system_playlist_details.assert_awaited_once_with(playlist_id)
153 provider._soundcloud.get_playlist_details.assert_not_awaited()
154 assert [track.item_id for track in tracks] == ["1"]
155