/
/
/
1"""Media retrieval operations for YouSee Musik."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING
6
7from music_assistant_models.enums import (
8 MediaType,
9)
10from music_assistant_models.errors import MediaNotFoundError
11from music_assistant_models.media_items import Album, Artist, Playlist, SearchResults, Track
12
13from music_assistant.providers.yousee.api_client import JsonLike
14from music_assistant.providers.yousee.constants import (
15 GET_POPULAR_TRACKS_LIMIT,
16 IMAGE_SIZE,
17)
18from music_assistant.providers.yousee.parsers import (
19 parse_album,
20 parse_artist,
21 parse_lyrics,
22 parse_playlist,
23 parse_track,
24)
25
26if TYPE_CHECKING:
27 from music_assistant.providers.yousee.provider import YouSeeMusikProvider
28
29
30class YouSeeMediaManager:
31 """Handles retrieval of media items from YouSee Musik."""
32
33 def __init__(self, provider: YouSeeMusikProvider):
34 """Initialize media retriever."""
35 self.provider = provider
36 self.api = provider.api
37 self.logger = provider.logger
38
39 async def search(
40 self,
41 search_query: str,
42 media_types: list[MediaType],
43 limit: int = 5,
44 ) -> SearchResults:
45 """Perform search on musicprovider.
46
47 :param search_query: Search query.
48 :param media_types: A list of media_types to include.
49 :param limit: Number of items to return in the search (per type).
50 """
51 sections = {
52 MediaType.TRACK: """
53 tracks(first: $first) {
54 items {
55 id
56 title
57 availableToStream
58 album {
59 id
60 title
61 }
62 artist {
63 id
64 title
65 cover(size: $imageSize)
66 }
67 cover(size: $imageSize)
68 duration
69 share
70 genre
71 isrc
72 featuredArtists {
73 items {
74 id
75 title
76 cover(size: $imageSize)
77 }
78 }
79 }
80 }
81 """,
82 MediaType.ALBUM: """
83 albums(first: $first) {
84 items {
85 id
86 title
87 cover(size: $imageSize)
88 artist {
89 id
90 title
91 cover(size: $imageSize)
92 }
93 }
94 }
95 """,
96 MediaType.ARTIST: """
97 artists(first: $first) {
98 items {
99 id
100 title
101 cover(size: $imageSize)
102 share
103 }
104 }
105 """,
106 MediaType.PLAYLIST: """
107 playlists(first: $first) {
108 items {
109 id
110 title
111 isOwned
112 share
113 cover(size: $imageSize)
114 description
115 }
116 }
117 """,
118 }
119
120 search_result = SearchResults()
121
122 media_types = [x for x in media_types if x in (sections)]
123
124 if not media_types:
125 return search_result
126
127 query = """
128 query searchMixedSections($criterion: String!, $imageSize: Int = 512, $first: Int = 5) {
129 search(criterion: $criterion) {
130 TRACK_SECTION
131 ALBUM_SECTION
132 PLAYLIST_SECTION
133 ARTIST_SECTION
134 }
135 }
136 """
137 for media_type, section in sections.items():
138 if media_type in media_types:
139 query = query.replace(f"{media_type.name}_SECTION", section)
140 else:
141 query = query.replace(f"{media_type.name}_SECTION", "")
142
143 variables = {
144 "criterion": search_query,
145 "imageSize": IMAGE_SIZE,
146 "first": limit,
147 }
148
149 result = await self.api.post_graphql(query, variables)
150
151 result = result.get("data", {}).get("search", {})
152
153 if not result:
154 return search_result
155
156 if "artists" in result:
157 search_result.artists = [
158 parse_artist(self.provider, item) for item in result["artists"].get("items", [])
159 ]
160 if "albums" in result:
161 search_result.albums = [
162 await parse_album(self.provider, item) for item in result["albums"].get("items", [])
163 ]
164 if "tracks" in result:
165 search_result.tracks = [
166 await parse_track(self.provider, item) for item in result["tracks"].get("items", [])
167 ]
168 if "playlists" in result:
169 search_result.playlists = [
170 await parse_playlist(self.provider, item)
171 for item in result["playlists"].get("items", [])
172 ]
173
174 return search_result
175
176 async def get_artist(self, prov_artist_id: str) -> Artist:
177 """Get full artist details by id."""
178 query = """
179 query Catalog($id: ID!, $imageSize: Int = 512) {
180 catalog {
181 artist(id: $id) {
182 id
183 title
184 cover(size: $imageSize)
185 share
186 }
187 }
188 }
189 """
190 variables = {"id": prov_artist_id, "imageSize": IMAGE_SIZE}
191
192 result = await self.api.post_graphql(query, variables)
193 if not result or not result.get("data", {}).get("catalog", {}).get("artist"):
194 raise MediaNotFoundError(f"Artist {prov_artist_id} not found")
195 return parse_artist(self.provider, result["data"]["catalog"]["artist"])
196
197 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
198 """Get a list of all albums for the given artist."""
199 query = """
200 query Catalog($id: ID!, $imageSize: Int = 512, $first: Int = 50, $after: String) {
201 catalog {
202 artist(id: $id) {
203 id
204 albums(first: $first, after: $after) {
205 totalCount
206 pageInfo {
207 hasNextPage
208 endCursor
209 }
210 items {
211 id
212 title
213 cover(size: $imageSize)
214 }
215 }
216 }
217 }
218 }
219 """
220
221 albums = []
222 variables = {
223 "id": prov_artist_id,
224 "imageSize": IMAGE_SIZE,
225 }
226
227 async for item in self.api.paginate_graphql(
228 query,
229 variables,
230 ["data", "catalog", "artist", "albums"],
231 ):
232 albums.append(await parse_album(self.provider, item))
233
234 return albums
235
236 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
237 """Get a list of most popular tracks for the given artist."""
238 query = """
239 query Catalog($id: ID!, $imageSize: Int = 512, $first: Int = 25) {
240 catalog {
241 artist(id: $id) {
242 id
243 title
244 cover(size: $imageSize)
245 share
246 tracks(first: $first, after: null, orderBy: POPULARITY) {
247 items {
248 id
249 title
250 cover(size: $imageSize)
251 isrc
252 duration
253 label
254 artist {
255 id
256 title
257 cover(size: $imageSize)
258 }
259 featuredArtists {
260 items {
261 id
262 title
263 cover(size: $imageSize)
264 }
265 }
266 share
267 genre
268 }
269 }
270 }
271 }
272 }
273 """
274
275 variables = {
276 "id": prov_artist_id,
277 "imageSize": IMAGE_SIZE,
278 "first": GET_POPULAR_TRACKS_LIMIT,
279 }
280
281 result = await self.api.post_graphql(query, variables)
282
283 if not result or not result.get("data", {}).get("catalog", {}).get("artist"):
284 raise MediaNotFoundError(f"Artist {prov_artist_id} not found")
285 tracks = []
286
287 for item in result["data"]["catalog"]["artist"]["tracks"]["items"]:
288 tracks.append(await parse_track(self.provider, item))
289
290 return tracks
291
292 async def get_album(self, prov_album_id: str) -> Album:
293 """Get full album details by id."""
294 query = """
295 query Catalog($id: ID!, $imageSize: Int = 512) {
296 catalog {
297 album(id: $id) {
298 id
299 title
300 tracksCount
301 genre
302 label
303 releaseDate
304 available
305 upc
306 type
307 share
308 cover(size: $imageSize)
309 artist {
310 id
311 title
312 cover(size: $imageSize)
313 }
314 featuredArtists {
315 items {
316 id
317 title
318 cover(size: $imageSize)
319 }
320 }
321 }
322 }
323 }
324 """
325 variables = {"id": prov_album_id, "imageSize": IMAGE_SIZE}
326
327 result = await self.api.post_graphql(query, variables)
328 if not result or not result.get("data", {}).get("catalog", {}).get("album"):
329 raise MediaNotFoundError(f"Album {prov_album_id} not found")
330 return await parse_album(self.provider, result["data"]["catalog"]["album"])
331
332 async def _get_lyrics(self, prov_track_id: str) -> list[JsonLike]:
333 """Attempt to retrieve lyrics for the given track id."""
334 query = """
335 query Lyric($id: ID!, $first: Int = 50, $after: String) {
336 catalog {
337 track(id: $id) {
338 lyrics {
339 lrc(first: $first, after: $after) {
340 pageInfo {
341 hasNextPage
342 endCursor
343 }
344 items {
345 startInMs
346 durationInMs
347 line
348 }
349 }
350 }
351 }
352 }
353 }
354 """
355 variables = {"id": prov_track_id}
356
357 lines = []
358
359 async for line in self.api.paginate_graphql(
360 query, variables, ["data", "catalog", "track", "lyrics", "lrc"]
361 ):
362 lines.append(line)
363
364 return lines
365
366 async def get_track(self, prov_track_id: str) -> Track:
367 """Get full track details by id."""
368 query = """
369 query getTrack($id: ID!, $imageSize: Int = 512) {
370 catalog {
371 track(id: $id) {
372 id
373 title
374 duration
375 genre
376 label
377 releaseDate
378 availableToStream
379 isrc
380 share
381 cover(size: $imageSize)
382 lyrics {
383 id
384 }
385 album {
386 id
387 title
388 }
389 artist {
390 id
391 title
392 cover(size: $imageSize)
393 }
394 featuredArtists {
395 items {
396 id
397 title
398 cover(size: $imageSize)
399 }
400 }
401 }
402 }
403 }
404 """
405 variables = {"id": prov_track_id, "imageSize": IMAGE_SIZE}
406
407 result = await self.api.post_graphql(query, variables)
408 if not result or not result.get("data", {}).get("catalog", {}).get("track"):
409 raise MediaNotFoundError(f"Track {prov_track_id} not found")
410
411 track = await parse_track(self.provider, result["data"]["catalog"]["track"])
412
413 if result["data"]["catalog"]["track"].get("lyrics"):
414 lyrics = await self._get_lyrics(prov_track_id)
415 parsed_lyrics, parsed_lrc_lyrics = await parse_lyrics(lyrics)
416
417 if parsed_lyrics:
418 self.logger.debug("Attached lyrics to track")
419 track.metadata.lyrics = parsed_lyrics
420 if parsed_lrc_lyrics:
421 self.logger.debug("Attached LRC lyrics to track")
422 track.metadata.lrc_lyrics = parsed_lrc_lyrics
423
424 return track
425
426 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
427 """Get full playlist details by id."""
428 query = """
429 query getPlaylist($id: ID!, $imageSize: Int = 512) {
430 playlists {
431 playlist(id: $id) {
432 id
433 title
434 description
435 tracksCount
436 createdAt
437 isOwned
438 share
439 cover(size: $imageSize)
440 }
441 }
442 }
443 """
444 variables = {"id": prov_playlist_id, "imageSize": IMAGE_SIZE}
445
446 result = await self.api.post_graphql(query, variables)
447 if not result or not result.get("data", {}).get("playlists", {}).get("playlist"):
448 raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found")
449
450 return await parse_playlist(self.provider, result["data"]["playlists"]["playlist"])
451
452 async def get_album_tracks(
453 self,
454 prov_album_id: str,
455 ) -> list[Track]:
456 """Get album tracks for given album id."""
457 query = """
458 query GetAlbum($id: ID!, $imageSize: Int = 512, $first: Int = 50, $after: String) {
459 catalog {
460 album(id: $id) {
461 id
462 tracks(first: $first, after: $after) {
463 items {
464 id
465 title
466 cover(size: $imageSize)
467 isrc
468 duration
469 label
470 artist {
471 id
472 title
473 cover(size: $imageSize)
474 }
475 featuredArtists {
476 items {
477 id
478 title
479 cover(size: $imageSize)
480 }
481 }
482 share
483 genre
484 }
485 pageInfo {
486 hasNextPage
487 endCursor
488 }
489 }
490 }
491 }
492 }
493 """
494 tracks = []
495 variables = {
496 "id": prov_album_id,
497 "imageSize": IMAGE_SIZE,
498 }
499
500 i = 1
501 async for item in self.api.paginate_graphql(
502 query,
503 variables,
504 ["data", "catalog", "album", "tracks"],
505 ):
506 track = await parse_track(self.provider, item)
507 track.position = i
508 tracks.append(track)
509 i += 1
510
511 return tracks
512
513 async def get_playlist_tracks(
514 self,
515 prov_playlist_id: str,
516 page: int = 0,
517 ) -> list[Track]:
518 """Get all playlist tracks for given playlist id."""
519 query = """
520 query getPlaylist($id: ID!, $imageSize: Int = 512, $first: Int = 50, $after: String) {
521 playlists {
522 playlist(id: $id) {
523 id
524 tracks(first: $first, after: $after) {
525 items {
526 id
527 title
528 cover(size: $imageSize)
529 isrc
530 duration
531 label
532 artist {
533 id
534 title
535 cover(size: $imageSize)
536 }
537 featuredArtists {
538 items {
539 id
540 title
541 cover(size: $imageSize)
542 }
543 }
544 share
545 genre
546 }
547 pageInfo {
548 hasNextPage
549 endCursor
550 }
551 }
552 }
553 }
554 }
555 """
556 tracks: list[Track] = []
557
558 if page > 0:
559 # paging not supported, we always return the whole list at once
560 return []
561 # TODO: access the underlying paging on the yousee api (if possible))
562
563 variables = {
564 "id": prov_playlist_id,
565 "imageSize": IMAGE_SIZE,
566 }
567
568 i = 1
569 async for item in self.api.paginate_graphql(
570 query, variables, ["data", "playlists", "playlist", "tracks"]
571 ):
572 track = await parse_track(self.provider, item)
573 track.position = i
574 tracks.append(track)
575 i += 1
576
577 return tracks
578
579 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
580 """Retrieve a dynamic list of similar tracks based on the provided track."""
581 query = """
582 query similarTracks($id: ID!, $first: Int = 25, $imageSize: Int = 512) {
583 catalog {
584 track(id: $id) {
585 id
586 similarTracks(first: $first) {
587 items {
588 id
589 title
590 cover(size: $imageSize)
591 isrc
592 duration
593 label
594 artist {
595 id
596 title
597 cover(size: $imageSize)
598 }
599 featuredArtists {
600 items {
601 id
602 title
603 cover(size: $imageSize)
604 }
605 }
606 share
607 genre
608 }
609 }
610 }
611 }
612 }
613 """
614
615 variables = {
616 "id": prov_track_id,
617 "first": limit,
618 "imageSize": IMAGE_SIZE,
619 }
620 result = await self.api.post_graphql(query, variables)
621 if not result or not result.get("data", {}).get("catalog", {}).get("track"):
622 raise MediaNotFoundError(f"Track {prov_track_id} not found")
623
624 return [
625 await parse_track(self.provider, item)
626 for item in result["data"]["catalog"]["track"]["similarTracks"]["items"]
627 ]
628