/
/
/
1"""Response parsing helpers for QQ Music provider."""
2
3from __future__ import annotations
4
5from collections.abc import Callable
6from contextlib import suppress
7from datetime import datetime
8from typing import Any
9
10from music_assistant_models.enums import ImageType, MediaType
11from music_assistant_models.errors import InvalidDataError
12from music_assistant_models.media_items import (
13 Album,
14 Artist,
15 AudioFormat,
16 ItemMapping,
17 MediaItemImage,
18 Playlist,
19 ProviderMapping,
20 Track,
21 UniqueList,
22)
23
24from music_assistant.helpers.util import parse_title_and_version
25
26from .helpers import (
27 clean_text,
28 extract_album_mid,
29 extract_artist_mid,
30 extract_first_text,
31 extract_playlist_ids,
32 extract_track_mid,
33 normalize_image_url,
34)
35
36
37def extract_song_id(track_obj: dict[str, Any]) -> int | None:
38 """Extract numeric QQ song id from a track-like payload."""
39 for key in ("id", "songid", "song_id", "songID"):
40 raw_val = track_obj.get(key)
41 if raw_val is None:
42 continue
43 try:
44 song_id = int(raw_val)
45 except TypeError, ValueError:
46 continue
47 if song_id > 0:
48 return song_id
49 return None
50
51
52def extract_items(data: dict[str, Any], candidate_keys: tuple[str, ...]) -> list[dict[str, Any]]:
53 """Extract list-like payload from possible response keys."""
54 for key in candidate_keys:
55 val = data.get(key)
56 if isinstance(val, list):
57 return [item for item in val if isinstance(item, dict)]
58 if isinstance(val, dict):
59 for nested_key in ("list", "v_playlist", "album_list", "songlist", "items"):
60 nested_val = val.get(nested_key)
61 if isinstance(nested_val, list):
62 return [item for item in nested_val if isinstance(item, dict)]
63 return []
64
65
66def extract_guess_recommend_tracks(data: Any) -> list[dict[str, Any]]:
67 """Extract tracks from GuessRecommendResponse raw payload."""
68 if not isinstance(data, dict):
69 return []
70 tracks = data.get("songs") or data.get("Tracks")
71 if not isinstance(tracks, list):
72 return []
73 return [item for item in tracks if isinstance(item, dict)]
74
75
76def extract_radar_recommend_tracks(data: Any) -> list[dict[str, Any]]:
77 """Extract tracks from RadarRecommendResponse raw payload."""
78 if not isinstance(data, dict):
79 return []
80 songs = data.get("songs")
81 if isinstance(songs, list):
82 return [item for item in songs if isinstance(item, dict)]
83 vec_songs = data.get("VecSongs")
84 if not isinstance(vec_songs, list):
85 return []
86 result: list[dict[str, Any]] = []
87 for item in vec_songs:
88 if not isinstance(item, dict):
89 continue
90 track = item.get("Track")
91 if isinstance(track, dict):
92 result.append(track)
93 return result
94
95
96def extract_newsong_tracks(data: Any) -> list[dict[str, Any]]:
97 """Extract tracks from RecommendNewSongResponse raw payload."""
98 if not isinstance(data, dict):
99 return []
100 songs = data.get("songs") or data.get("songlist")
101 if not isinstance(songs, list):
102 return []
103 return [item for item in songs if isinstance(item, dict)]
104
105
106def extract_recommend_songlists(data: Any) -> list[dict[str, Any]]:
107 """Extract playlist basics from RecommendSonglistResponse raw payload."""
108 if not isinstance(data, dict):
109 return []
110 songlists = data.get("songlists")
111 if isinstance(songlists, list):
112 return [item for item in songlists if isinstance(item, dict)]
113 items = data.get("List")
114 if not isinstance(items, list):
115 return []
116 result: list[dict[str, Any]] = []
117 for item in items:
118 if not isinstance(item, dict):
119 continue
120 playlist_obj = item.get("Playlist")
121 if not isinstance(playlist_obj, dict):
122 continue
123 basic = playlist_obj.get("basic")
124 if not isinstance(basic, dict):
125 continue
126 merged = dict(basic)
127 cover_obj = playlist_obj.get("cover")
128 if isinstance(cover_obj, dict):
129 cover_url = normalize_image_url(
130 cover_obj.get("default_url")
131 or cover_obj.get("medium_url")
132 or cover_obj.get("big_url")
133 or cover_obj.get("small_url")
134 )
135 if cover_url and not merged.get("picurl"):
136 merged["picurl"] = cover_url
137 creator_obj = playlist_obj.get("creator")
138 if isinstance(creator_obj, dict) and not merged.get("creator"):
139 merged["creator"] = creator_obj
140 if playlist_obj.get("desc") and not merged.get("desc"):
141 merged["desc"] = playlist_obj.get("desc")
142 result.append(merged)
143 return result
144
145
146def get_artist_mapping(
147 artist_obj: dict[str, Any] | str, provider_instance_id: str
148) -> ItemMapping | None:
149 """Build artist item mapping from QQ artist payload."""
150 if isinstance(artist_obj, str):
151 return None
152 artist_id = extract_artist_mid(artist_obj)
153 if not artist_id:
154 return None
155 artist_name = extract_first_text(
156 artist_obj,
157 (
158 "name",
159 "title",
160 "singerName",
161 "singername",
162 "singer_name",
163 "title_main",
164 "search_title",
165 "singerName_hilight",
166 "name_hilight",
167 "title_hilight",
168 ),
169 "Unknown Artist",
170 )
171 return ItemMapping(
172 media_type=MediaType.ARTIST,
173 item_id=artist_id,
174 provider=provider_instance_id,
175 name=artist_name,
176 )
177
178
179def parse_artist(
180 artist_obj: dict[str, Any], provider_domain: str, provider_instance_id: str
181) -> Artist:
182 """Parse raw qqmusic artist object."""
183 artist_id = extract_artist_mid(artist_obj)
184 if not artist_id:
185 raise InvalidDataError("Artist object does not contain id/mid")
186 artist_name = extract_first_text(
187 artist_obj,
188 (
189 "name",
190 "Name",
191 "title",
192 "singerName",
193 "singername",
194 "singer_name",
195 "SingerName",
196 "title_main",
197 "search_title",
198 "singerName_hilight",
199 "name_hilight",
200 "title_hilight",
201 "singer_hilight",
202 "matchName",
203 "match_name",
204 "nickname",
205 "nick",
206 ),
207 "Unknown Artist",
208 )
209 artist = Artist(
210 item_id=artist_id,
211 provider=provider_instance_id,
212 name=artist_name,
213 provider_mappings={
214 ProviderMapping(
215 item_id=artist_id,
216 provider_domain=provider_domain,
217 provider_instance=provider_instance_id,
218 url=f"https://y.qq.com/n/ryqq/singer/{artist_id}",
219 )
220 },
221 )
222 subtitle = clean_text(
223 artist_obj.get("subtitle") or artist_obj.get("desc") or artist_obj.get("description") or "",
224 "",
225 )
226 if subtitle:
227 artist.metadata.description = subtitle
228 artist.metadata.description_language = "zh"
229 artist_image = (
230 normalize_image_url(
231 artist_obj.get("singerPic")
232 or artist_obj.get("pic")
233 or artist_obj.get("avatar")
234 or artist_obj.get("Avatar")
235 or artist_obj.get("AvatarUrl")
236 or artist_obj.get("singer_pic")
237 )
238 or f"https://y.qq.com/music/photo_new/T001R500x500M000{artist_id}.jpg"
239 )
240 artist.metadata.images = UniqueList(
241 [
242 MediaItemImage(
243 type=ImageType.THUMB,
244 path=artist_image,
245 provider=provider_instance_id,
246 remotely_accessible=True,
247 )
248 ]
249 )
250 return artist
251
252
253def parse_album(
254 album_obj: dict[str, Any],
255 provider_domain: str,
256 provider_instance_id: str,
257) -> Album:
258 """Parse raw qqmusic album object."""
259 album_id = extract_album_mid(album_obj)
260 if not album_id:
261 raise InvalidDataError("Album object does not contain id/mid")
262 raw_album_name = str(
263 album_obj.get("title")
264 or album_obj.get("name")
265 or album_obj.get("albumName")
266 or "Unknown Album"
267 )
268 album_subtitle = clean_text(
269 album_obj.get("subtitle")
270 or album_obj.get("albumTranName")
271 or album_obj.get("title_extra")
272 or "",
273 "",
274 )
275 album_name, album_version = parse_title_and_version(raw_album_name, album_subtitle)
276 album_name = clean_text(album_name, "Unknown Album")
277 album = Album(
278 item_id=album_id,
279 provider=provider_instance_id,
280 name=album_name,
281 version=album_version,
282 provider_mappings={
283 ProviderMapping(
284 item_id=album_id,
285 provider_domain=provider_domain,
286 provider_instance=provider_instance_id,
287 url=f"https://y.qq.com/n/ryqq/albumDetail/{album_id}",
288 )
289 },
290 )
291 if release_str := album_obj.get("time_public") or album_obj.get("publishDate"):
292 with suppress(ValueError):
293 album.year = datetime.strptime(release_str, "%Y-%m-%d").year # noqa: DTZ007
294 album_desc = clean_text(
295 album_obj.get("description")
296 or album_obj.get("description2")
297 or album_obj.get("desc")
298 or "",
299 "",
300 )
301 if album_desc:
302 album.metadata.description = album_desc
303 album.metadata.description_language = "zh"
304 artist_candidates = album_obj.get("singer")
305 if isinstance(artist_candidates, dict):
306 artist_iterable: list[dict[str, Any] | str] = [artist_candidates]
307 elif isinstance(artist_candidates, list):
308 artist_iterable = artist_candidates
309 elif isinstance(artist_candidates, str):
310 artist_iterable = [artist_candidates]
311 else:
312 singer_list = album_obj.get("singer_list")
313 artist_iterable = singer_list if isinstance(singer_list, list) else []
314 for artist_obj in artist_iterable:
315 if artist_mapping := get_artist_mapping(artist_obj, provider_instance_id):
316 album.artists.append(artist_mapping)
317 cover_path = normalize_image_url(
318 album_obj.get("pic")
319 or album_obj.get("picUrl")
320 or album_obj.get("picurl")
321 or album_obj.get("logo")
322 or album_obj.get("cover")
323 )
324 if cover_path:
325 album.metadata.images = UniqueList(
326 [
327 MediaItemImage(
328 type=ImageType.THUMB,
329 path=cover_path,
330 provider=provider_instance_id,
331 remotely_accessible=True,
332 )
333 ]
334 )
335 else:
336 album.metadata.images = UniqueList(
337 [
338 MediaItemImage(
339 type=ImageType.THUMB,
340 path=f"https://y.qq.com/music/photo_new/T002R500x500M000{album_id}.jpg",
341 provider=provider_instance_id,
342 remotely_accessible=True,
343 )
344 ]
345 )
346 return album
347
348
349def parse_track( # noqa: PLR0915
350 track_obj: dict[str, Any],
351 provider_domain: str,
352 provider_instance_id: str,
353 get_max_supported_audio_format: Callable[[dict[str, Any]], tuple[AudioFormat, str | None]],
354) -> Track:
355 """Parse raw qqmusic track object."""
356 track_id = extract_track_mid(track_obj)
357 if not track_id:
358 raise InvalidDataError("Track object does not contain id/mid")
359 raw_track_name = clean_text(
360 track_obj.get("title") or track_obj.get("name"),
361 "Unknown Track",
362 )
363 track_subtitle = clean_text(
364 track_obj.get("subtitle") or track_obj.get("title_extra") or "",
365 "",
366 )
367 track_name, track_version = parse_title_and_version(raw_track_name, track_subtitle)
368 duration = track_obj.get("interval")
369 max_audio_format, max_quality_label = get_max_supported_audio_format(track_obj)
370 track = Track(
371 item_id=track_id,
372 provider=provider_instance_id,
373 name=track_name,
374 version=track_version,
375 duration=int(duration) if duration else 0,
376 provider_mappings={
377 ProviderMapping(
378 item_id=track_id,
379 provider_domain=provider_domain,
380 provider_instance=provider_instance_id,
381 audio_format=max_audio_format,
382 url=f"https://y.qq.com/n/ryqq/songDetail/{track_id}",
383 details=max_quality_label,
384 )
385 },
386 )
387 track_desc = clean_text(
388 track_obj.get("desc") or track_obj.get("content") or "",
389 "",
390 )
391 if track_desc:
392 track.metadata.description = track_desc
393 track.metadata.description_language = "zh"
394 album_obj = track_obj.get("album")
395 album_id = ""
396 album_name = ""
397 if isinstance(album_obj, dict):
398 album_id = str(
399 album_obj.get("mid")
400 or album_obj.get("albumMid")
401 or album_obj.get("albumMID")
402 or album_obj.get("albummid")
403 or album_obj.get("albumID")
404 or album_obj.get("albumid")
405 or album_obj.get("id")
406 or ""
407 )
408 album_name = clean_text(
409 album_obj.get("name")
410 or album_obj.get("title")
411 or album_obj.get("albumName")
412 or album_obj.get("albumTitle")
413 )
414 if not album_id:
415 album_id = str(
416 track_obj.get("albummid")
417 or track_obj.get("albumMid")
418 or track_obj.get("albumMID")
419 or track_obj.get("albumid")
420 or track_obj.get("albumID")
421 or ""
422 )
423 if not album_name:
424 album_name = clean_text(
425 track_obj.get("albumname")
426 or track_obj.get("albumName")
427 or track_obj.get("albumTitle")
428 or track_obj.get("album_title")
429 )
430 if album_id and album_name:
431 track.album = Album(
432 item_id=album_id,
433 provider=provider_instance_id,
434 name=album_name,
435 provider_mappings={
436 ProviderMapping(
437 item_id=album_id,
438 provider_domain=provider_domain,
439 provider_instance=provider_instance_id,
440 url=f"https://y.qq.com/n/ryqq/albumDetail/{album_id}",
441 )
442 },
443 )
444
445 singer_list: list[dict[str, Any] | str]
446 raw_singer = track_obj.get("singer") or track_obj.get("singer_list") or track_obj.get("singers")
447 if isinstance(raw_singer, dict):
448 singer_list = [raw_singer]
449 elif isinstance(raw_singer, list):
450 singer_list = raw_singer
451 elif isinstance(raw_singer, str):
452 singer_list = [raw_singer]
453 else:
454 singer_list = []
455 for artist_obj in singer_list:
456 if artist_mapping := get_artist_mapping(artist_obj, provider_instance_id):
457 track.artists.append(artist_mapping)
458 if not track.artists:
459 fallback_artist_id = str(
460 track_obj.get("singerMid")
461 or track_obj.get("singerMID")
462 or track_obj.get("singer_mid")
463 or ""
464 )
465 fallback_artist_name = clean_text(
466 track_obj.get("singerName")
467 or track_obj.get("singername")
468 or track_obj.get("singer_name")
469 or ""
470 )
471 if fallback_artist_id and fallback_artist_name:
472 track.artists.append(
473 ItemMapping(
474 media_type=MediaType.ARTIST,
475 item_id=fallback_artist_id,
476 provider=provider_instance_id,
477 name=fallback_artist_name,
478 )
479 )
480
481 cover_id = ""
482 if isinstance(album_obj, dict):
483 cover_id = str(
484 album_obj.get("mid")
485 or album_obj.get("albumMid")
486 or album_obj.get("albumMID")
487 or album_obj.get("albummid")
488 or ""
489 )
490 if not cover_id:
491 cover_id = album_id
492 if cover_id:
493 if isinstance(track.album, Album):
494 track.album.metadata.images = UniqueList(
495 [
496 MediaItemImage(
497 type=ImageType.THUMB,
498 path=f"https://y.qq.com/music/photo_new/T002R500x500M000{cover_id}.jpg",
499 provider=provider_instance_id,
500 remotely_accessible=True,
501 )
502 ]
503 )
504 track.metadata.images = UniqueList(
505 [
506 MediaItemImage(
507 type=ImageType.THUMB,
508 path=f"https://y.qq.com/music/photo_new/T002R500x500M000{cover_id}.jpg",
509 provider=provider_instance_id,
510 remotely_accessible=True,
511 )
512 ]
513 )
514 return track
515
516
517def build_playlist_id(dissid: int | str, dirid: int | str) -> str:
518 """Build provider playlist id from dissid and dirid."""
519 return f"{dissid}:{dirid}"
520
521
522def parse_playlist_id(prov_playlist_id: str) -> tuple[int, int]:
523 """Parse provider playlist id into (dissid, dirid)."""
524 if ":" in prov_playlist_id:
525 dissid_raw, dirid_raw = prov_playlist_id.split(":", 1)
526 return (int(dissid_raw), int(dirid_raw))
527 return (int(prov_playlist_id), 0)
528
529
530def parse_playlist(
531 playlist_obj: dict[str, Any],
532 provider_domain: str,
533 provider_instance_id: str,
534) -> Playlist:
535 """Parse raw qqmusic playlist object."""
536 dissid, dirid = extract_playlist_ids(playlist_obj)
537 if not dissid:
538 raise InvalidDataError("Playlist object missing dissid/tid")
539 playlist_id = build_playlist_id(dissid, dirid)
540 playlist_name = clean_text(
541 playlist_obj.get("dirName")
542 or playlist_obj.get("dirname")
543 or playlist_obj.get("name")
544 or playlist_obj.get("title")
545 or playlist_obj.get("search_title")
546 or playlist_obj.get("name_hilight")
547 or playlist_obj.get("title_hilight")
548 or playlist_obj.get("diss_name")
549 or playlist_obj.get("dissname")
550 or "QQ Music Playlist",
551 "QQ Music Playlist",
552 )
553 playlist = Playlist(
554 item_id=playlist_id,
555 provider=provider_instance_id,
556 name=playlist_name,
557 provider_mappings={
558 ProviderMapping(
559 item_id=playlist_id,
560 provider_domain=provider_domain,
561 provider_instance=provider_instance_id,
562 url=f"https://y.qq.com/n/ryqq/playlist/{dissid}",
563 )
564 },
565 )
566 owner_name = str(
567 playlist_obj.get("creator", {}).get("name")
568 or playlist_obj.get("creator", {}).get("nick")
569 or playlist_obj.get("nickname")
570 or playlist_obj.get("nick")
571 or ""
572 )
573 if owner_name:
574 playlist.owner = owner_name
575 description = clean_text(
576 playlist_obj.get("desc") or playlist_obj.get("description"),
577 "",
578 )
579 if description:
580 playlist.metadata.description = description
581 playlist.metadata.description_language = "zh"
582
583 cover_val = playlist_obj.get("cover")
584 cover = ""
585 if isinstance(cover_val, dict):
586 cover = normalize_image_url(
587 cover_val.get("default_url")
588 or cover_val.get("medium_url")
589 or cover_val.get("big_url")
590 or cover_val.get("small_url")
591 )
592 if not cover:
593 cover = normalize_image_url(
594 cover_val
595 or playlist_obj.get("logo")
596 or playlist_obj.get("picurl")
597 or playlist_obj.get("cover_url_big")
598 or playlist_obj.get("pic")
599 or playlist_obj.get("picUrl")
600 )
601 if cover:
602 playlist.metadata.images = UniqueList(
603 [
604 MediaItemImage(
605 type=ImageType.THUMB,
606 path=cover,
607 provider=provider_instance_id,
608 remotely_accessible=True,
609 )
610 ]
611 )
612 return playlist
613