/
/
/
1"""Unit tests for QQ Music provider utility helpers."""
2
3# mypy: ignore-errors
4
5from __future__ import annotations
6
7from types import SimpleNamespace
8
9import pytest
10from music_assistant_models.enums import ProviderFeature
11from music_assistant_models.errors import MediaNotFoundError
12from music_assistant_models.media_items import Album
13from qqmusic_api.modules.song import SongFileType
14
15from music_assistant.providers.qqmusic import (
16 SUPPORTED_FEATURES,
17 QQMusicProvider,
18)
19from music_assistant.providers.qqmusic.constants import (
20 CONF_QUALITY,
21 QUALITY_HI_RES,
22)
23
24
25def test_parse_playlist_id_composite() -> None:
26 """Composite playlist id should parse into dissid/dirid."""
27 provider = QQMusicProvider.__new__(QQMusicProvider)
28 dissid, dirid = provider._parse_playlist_id("12345:678")
29 assert dissid == 12345
30 assert dirid == 678
31
32
33def test_parse_playlist_id_legacy_single() -> None:
34 """Single numeric playlist id should fallback to dirid 0."""
35 provider = QQMusicProvider.__new__(QQMusicProvider)
36 dissid, dirid = provider._parse_playlist_id("12345")
37 assert dissid == 12345
38 assert dirid == 0
39
40
41def test_parse_playlist_id_invalid_raises() -> None:
42 """Invalid playlist id should raise ValueError."""
43 provider = QQMusicProvider.__new__(QQMusicProvider)
44 with pytest.raises(ValueError, match="invalid literal"):
45 provider._parse_playlist_id("invalid:value")
46
47
48def test_supported_features_include_phase1_capabilities() -> None:
49 """QQ Music should expose phase-1 capabilities in provider features."""
50 assert ProviderFeature.SIMILAR_TRACKS in SUPPORTED_FEATURES
51 assert ProviderFeature.PLAYLIST_CREATE in SUPPORTED_FEATURES
52 assert ProviderFeature.PLAYLIST_TRACKS_EDIT in SUPPORTED_FEATURES
53 assert ProviderFeature.LYRICS in SUPPORTED_FEATURES
54
55
56def test_extract_song_id_from_track_payload() -> None:
57 """Track parser helper should extract numeric song id from common keys."""
58 provider = QQMusicProvider.__new__(QQMusicProvider)
59 assert provider._extract_song_id({"id": 123}) == 123
60 assert provider._extract_song_id({"songid": "456"}) == 456
61 assert provider._extract_song_id({"songID": "789"}) == 789
62 assert provider._extract_song_id({"song_id": "bad"}) is None
63
64
65def test_get_candidate_file_types_hires_with_fallback_chain() -> None:
66 """Hi-Res preference should fall back to FLAC -> MP3 320 -> MP3 128."""
67 provider = QQMusicProvider.__new__(QQMusicProvider)
68 provider.config = SimpleNamespace( # type: ignore[attr-defined]
69 get_value=lambda key: QUALITY_HI_RES if key == CONF_QUALITY else None
70 )
71
72 candidates = provider._get_candidate_file_types()
73 assert candidates == [
74 SongFileType.MASTER,
75 SongFileType.FLAC,
76 SongFileType.MP3_320,
77 SongFileType.MP3_128,
78 ]
79
80
81def test_get_stream_audio_format_for_master() -> None:
82 """MASTER stream type should map to 24-bit/192kHz FLAC format."""
83 provider = QQMusicProvider.__new__(QQMusicProvider)
84
85 stream_format = provider._get_stream_audio_format(SongFileType.MASTER)
86 assert stream_format.content_type.value == "flac"
87 assert stream_format.bit_depth == 24
88 assert stream_format.sample_rate == 192000
89
90
91def test_get_artist_mapping_with_string_singer_returns_none() -> None:
92 """String-only singer payload should not create clickable artist mapping."""
93 provider = QQMusicProvider.__new__(QQMusicProvider)
94 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
95 mapping = provider._get_artist_mapping("çåå®")
96 assert mapping is None
97
98
99def test_parse_artist_with_singer_name_and_highlight() -> None:
100 """Artist parser should map singerName and strip search highlight tags."""
101 provider = QQMusicProvider.__new__(QQMusicProvider)
102 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
103 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
104 artist = provider._parse_artist(
105 {
106 "singerMid": "003Nz2So3XXYek",
107 "singerName": "<em>çåå®</em>",
108 "subtitle": "åè¯æµè¡ç·ææ",
109 }
110 )
111 assert artist.item_id == "003Nz2So3XXYek"
112 assert artist.name == "çåå®"
113 assert artist.metadata.description == "åè¯æµè¡ç·ææ"
114
115
116def test_parse_artist_uses_avatar_and_description() -> None:
117 """Artist parser should accept avatar url fields."""
118 provider = QQMusicProvider.__new__(QQMusicProvider)
119 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
120 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
121 artist = provider._parse_artist(
122 {
123 "singerMid": "003Nz2So3XXYek",
124 "Name": "çåå®",
125 "Avatar": "//y.qq.com/music/photo_new/T001R500x500M000003Nz2So3XXYek.jpg",
126 "desc": "åè¯æµè¡ææ",
127 }
128 )
129 assert artist.name == "çåå®"
130 assert artist.metadata.images
131 assert artist.metadata.images[0].path.startswith("https://")
132
133
134def test_parse_playlist_name_strips_highlight_tags() -> None:
135 """Playlist parser should strip highlight tags from search title."""
136 provider = QQMusicProvider.__new__(QQMusicProvider)
137 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
138 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
139 playlist = provider._parse_playlist({"id": 123, "title": "<em>çåå®</em>ç²¾é"})
140 assert playlist.name == "çåå®ç²¾é"
141
142
143def test_parse_playlist_detail_fields() -> None:
144 """Playlist parser should support dirname, normalized cover and description fields."""
145 provider = QQMusicProvider.__new__(QQMusicProvider)
146 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
147 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
148 playlist = provider._parse_playlist(
149 {
150 "dissid": 7843129912,
151 "dirid": 10,
152 "dirname": "æçæ¶è",
153 "desc": "è¿æ¯æåç®ä»",
154 "picurl": "//y.qq.com/music/photo_new/T003R500x500M0007843129912.jpg",
155 "creator": {"nick": "Alice"},
156 }
157 )
158 assert playlist.name == "æçæ¶è"
159 assert playlist.owner == "Alice"
160 assert playlist.metadata.description == "è¿æ¯æåç®ä»"
161 assert playlist.metadata.images
162 assert playlist.metadata.images[0].path.startswith("https://")
163
164
165def test_parse_track_album_mapping_accepts_album_mid_variants() -> None:
166 """Track parser should map album when album dict uses albumMid-style fields."""
167 provider = QQMusicProvider.__new__(QQMusicProvider)
168 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
169 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
170
171 track = provider._parse_track(
172 {
173 "mid": "003aAYrm3GE0Ac",
174 "title": "稻é¦",
175 "singer": [{"mid": "0025NhlN2yWrP4", "name": "卿°ä¼¦"}],
176 "album": {"albumMid": "001qu4I30eVFYb", "title": "éæ°åº§"},
177 }
178 )
179 assert track.album is not None
180 assert isinstance(track.album, Album)
181 assert track.album.item_id == "001qu4I30eVFYb"
182 assert track.album.name == "éæ°åº§"
183
184
185def test_parse_track_sets_version_and_description() -> None:
186 """Track parser should map subtitle to version and desc to metadata description."""
187 provider = QQMusicProvider.__new__(QQMusicProvider)
188 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
189 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
190
191 track = provider._parse_track(
192 {
193 "mid": "003aAYrm3GE0Ac",
194 "title": "å¯ä¸",
195 "title_extra": "Live",
196 "desc": "çé¨ç°åºç",
197 "singer": [{"mid": "003Nz2So3XXYek", "name": "çåå®"}],
198 }
199 )
200 assert track.name == "å¯ä¸"
201 assert track.version == "Live"
202 assert track.metadata.description == "çé¨ç°åºç"
203
204
205def test_parse_track_sets_max_quality_from_file_info() -> None:
206 """Track parser should expose max supported quality on provider mapping."""
207 provider = QQMusicProvider.__new__(QQMusicProvider)
208 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
209 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
210
211 track = provider._parse_track(
212 {
213 "mid": "003aAYrm3GE0Ac",
214 "title": "å¯ä¸",
215 "singer": [{"mid": "003Nz2So3XXYek", "name": "çåå®"}],
216 "file": {
217 "size_flac": 51200000,
218 "size_320mp3": 12000000,
219 "size_128mp3": 4000000,
220 },
221 }
222 )
223 provider_mapping = next(iter(track.provider_mappings))
224 assert provider_mapping.audio_format.content_type.value == "flac"
225 assert provider_mapping.audio_format.bit_depth == 16
226 assert provider_mapping.audio_format.sample_rate == 44100
227 assert provider_mapping.details is None
228
229
230def test_parse_track_sets_master_quality_when_available() -> None:
231 """Track parser should prefer master quality when QQ reports it in size_new."""
232 provider = QQMusicProvider.__new__(QQMusicProvider)
233 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
234 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
235
236 track = provider._parse_track(
237 {
238 "mid": "003aAYrm3GE0Ac",
239 "title": "å¯ä¸",
240 "singer": [{"mid": "003Nz2So3XXYek", "name": "çåå®"}],
241 "file": {
242 "size_new": [123456789, 0, 0, 0, 0, 0],
243 },
244 }
245 )
246 provider_mapping = next(iter(track.provider_mappings))
247 assert provider_mapping.audio_format.content_type.value == "flac"
248 assert provider_mapping.audio_format.bit_depth == 24
249 assert provider_mapping.audio_format.sample_rate == 192000
250 assert provider_mapping.details == "Hi-Res"
251
252
253def test_parse_track_sets_ogg_quality_when_mp3_fields_missing() -> None:
254 """Track parser should still expose quality label from OGG-only file metadata."""
255 provider = QQMusicProvider.__new__(QQMusicProvider)
256 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
257 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
258
259 track = provider._parse_track(
260 {
261 "mid": "003aAYrm3GE0Ac",
262 "title": "å¯ä¸",
263 "singer": [{"mid": "003Nz2So3XXYek", "name": "çåå®"}],
264 "file": {
265 "size_192ogg": 8000000,
266 "size_96ogg": 4000000,
267 },
268 }
269 )
270 provider_mapping = next(iter(track.provider_mappings))
271 assert provider_mapping.audio_format.content_type.value == "ogg"
272 assert provider_mapping.details is None
273
274
275def test_parse_album_sets_version_and_description() -> None:
276 """Album parser should map album subtitle/version and description fields."""
277 provider = QQMusicProvider.__new__(QQMusicProvider)
278 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
279 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
280
281 album = provider._parse_album(
282 {
283 "albumMid": "001qu4I30eVFYb",
284 "title": "å¯ä¸",
285 "albumTranName": "纪念ç",
286 "description": "ç»å
¸ä¸è¾",
287 "singer": [{"singerMid": "003Nz2So3XXYek", "singerName": "çåå®"}],
288 }
289 )
290 assert album.name == "å¯ä¸"
291 assert album.version == "纪念ç"
292 assert album.metadata.description == "ç»å
¸ä¸è¾"
293
294
295def test_parse_track_album_mapping_accepts_album_id_fallback() -> None:
296 """Track parser should map album from track-level albumID/albumTitle fields."""
297 provider = QQMusicProvider.__new__(QQMusicProvider)
298 provider.manifest = SimpleNamespace(domain="qqmusic") # type: ignore[attr-defined]
299 provider.config = SimpleNamespace(instance_id="qqmusic_instance") # type: ignore[attr-defined]
300
301 track = provider._parse_track(
302 {
303 "mid": "003aAYrm3GE0Ac",
304 "title": "稻é¦",
305 "albumID": 123456,
306 "albumTitle": "éæ°åº§",
307 "singerMid": "0025NhlN2yWrP4",
308 "singerName": "卿°ä¼¦",
309 }
310 )
311 assert track.album is not None
312 assert isinstance(track.album, Album)
313 assert track.album.item_id == "123456"
314 assert track.album.name == "éæ°åº§"
315 assert track.artists
316 assert track.artists[0].name == "卿°ä¼¦"
317
318
319@pytest.mark.asyncio
320async def test_get_config_entries_exposes_quality_option_without_actions() -> None:
321 """Migrated config entries expose only the quality option and no auth/QR actions."""
322 provider = QQMusicProvider.__new__(QQMusicProvider)
323 entries = await provider.get_config_entries()
324 quality_entry = next(entry for entry in entries if entry.key == CONF_QUALITY)
325 assert [option.value for option in quality_entry.options] == [
326 "mp3_128",
327 "mp3_320",
328 "flac",
329 "hi_res",
330 ]
331 # the action-driven QR/auth pseudo-flow entries are gone after the setup-flow migration
332 assert all(entry.action is None for entry in entries)
333
334
335@pytest.mark.asyncio
336async def test_get_artist_albums_fallback_uses_album_list_on_tab_error() -> None:
337 """Artist albums should fallback to album_list when AlbumTab endpoint fails."""
338 provider = QQMusicProvider.__new__(QQMusicProvider)
339
340 class _DummyTabType:
341 ALBUM = "album"
342
343 async def _raise_not_found(*_args, **_kwargs):
344 raise MediaNotFoundError("not found")
345
346 async def _get_album_list(*_args, **_kwargs):
347 return {
348 "albumList": [
349 {"albumMID": "alb_mid_1", "name": "ä¸è¾A"},
350 {"mid": "alb_mid_2", "name": "ä¸è¾B"},
351 ]
352 }
353
354 provider._qq_singer = SimpleNamespace( # type: ignore[attr-defined]
355 TabType=_DummyTabType,
356 get_tab_detail=_raise_not_found,
357 get_album_list=_get_album_list,
358 get_album_list_all=lambda *_args, **_kwargs: [],
359 )
360
361 async def _run_with_session(coro):
362 return await coro
363
364 provider._run_with_session = _run_with_session # type: ignore[attr-defined]
365 provider._extract_items = QQMusicProvider._extract_items.__get__(provider, QQMusicProvider) # type: ignore[attr-defined]
366 provider._parse_album = lambda item: str(item.get("albumMID") or item.get("mid")) # type: ignore[attr-defined]
367
368 albums = await QQMusicProvider.get_artist_albums.__wrapped__(provider, "003Nz2So3XXYek")
369 assert albums == ["alb_mid_1", "alb_mid_2"]
370