/
/
/
1"""KION Music provider implementation."""
2
3from __future__ import annotations
4
5import logging
6from collections.abc import Sequence
7from typing import TYPE_CHECKING
8
9from music_assistant_models.enums import MediaType, ProviderFeature
10from music_assistant_models.errors import (
11 InvalidDataError,
12 LoginFailed,
13 MediaNotFoundError,
14 ProviderUnavailableError,
15 ResourceTemporarilyUnavailable,
16)
17from music_assistant_models.media_items import (
18 Album,
19 Artist,
20 BrowseFolder,
21 ItemMapping,
22 MediaItemType,
23 Playlist,
24 ProviderMapping,
25 RecommendationFolder,
26 SearchResults,
27 Track,
28 UniqueList,
29)
30
31from music_assistant.controllers.cache import use_cache
32from music_assistant.models.music_provider import MusicProvider
33
34from .api_client import KionMusicClient
35from .constants import (
36 BROWSE_INITIAL_TRACKS,
37 BROWSE_NAMES_EN,
38 BROWSE_NAMES_RU,
39 CONF_BASE_URL,
40 CONF_TOKEN,
41 DEFAULT_BASE_URL,
42 DISCOVERY_INITIAL_TRACKS,
43 MY_MIX_BATCH_SIZE,
44 MY_MIX_MAX_TRACKS,
45 MY_MIX_PLAYLIST_ID,
46 PLAYLIST_ID_SPLITTER,
47 RADIO_TRACK_ID_SEP,
48 ROTOR_STATION_MY_MIX,
49 TRACK_BATCH_SIZE,
50)
51from .parsers import parse_album, parse_artist, parse_playlist, parse_track
52from .streaming import KionMusicStreamingManager
53
54if TYPE_CHECKING:
55 from collections.abc import AsyncGenerator
56
57 from music_assistant_models.streamdetails import StreamDetails
58
59
60def _parse_radio_item_id(item_id: str) -> tuple[str, str | None]:
61 """Extract track_id and optional station_id from provider item_id.
62
63 My Mix tracks use item_id format 'track_id@station_id'. Other tracks use
64 plain track_id.
65
66 :param item_id: Provider item_id (may contain RADIO_TRACK_ID_SEP).
67 :return: (track_id, station_id or None).
68 """
69 if RADIO_TRACK_ID_SEP in item_id:
70 parts = item_id.split(RADIO_TRACK_ID_SEP, 1)
71 return (parts[0], parts[1] if len(parts) > 1 else None)
72 return (item_id, None)
73
74
75class KionMusicProvider(MusicProvider):
76 """Implementation of a KION Music MusicProvider."""
77
78 _client: KionMusicClient | None = None
79 _streaming: KionMusicStreamingManager | None = None
80 _my_mix_batch_id: str | None = None
81 _my_mix_last_track_id: str | None = None # last track id for "Load more" (API queue param)
82 _my_mix_playlist_next_cursor: str | None = None # first_track_id for next playlist page
83 _my_mix_radio_started_sent: bool = False
84 _my_mix_seen_track_ids: set[str] # Track IDs seen in current My Mix session
85
86 @property
87 def client(self) -> KionMusicClient:
88 """Return the KION Music client."""
89 if self._client is None:
90 raise ProviderUnavailableError("Provider not initialized")
91 return self._client
92
93 @property
94 def streaming(self) -> KionMusicStreamingManager:
95 """Return the streaming manager."""
96 if self._streaming is None:
97 raise ProviderUnavailableError("Provider not initialized")
98 return self._streaming
99
100 def _get_browse_names(self) -> dict[str, str]:
101 """Get locale-based browse folder names."""
102 try:
103 locale = (self.mass.metadata.locale or "en_US").lower()
104 use_russian = locale.startswith("ru")
105 except Exception:
106 use_russian = False
107 return BROWSE_NAMES_RU if use_russian else BROWSE_NAMES_EN
108
109 async def handle_async_init(self) -> None:
110 """Handle async initialization of the provider."""
111 token = self.config.get_value(CONF_TOKEN)
112 if not token:
113 raise LoginFailed("No KION Music token provided")
114
115 base_url = self.config.get_value(CONF_BASE_URL, DEFAULT_BASE_URL)
116 self._client = KionMusicClient(str(token), base_url=str(base_url))
117 await self._client.connect()
118 # Suppress yandex_music library DEBUG dumps (full API request/response JSON)
119 logging.getLogger("yandex_music").setLevel(self.logger.level + 10)
120 self._streaming = KionMusicStreamingManager(self)
121 # Initialize My Mix duplicate tracking
122 self._my_mix_seen_track_ids = set()
123 self.logger.info("Successfully connected to KION Music")
124
125 async def unload(self, is_removed: bool = False) -> None:
126 """Handle unload/close of the provider.
127
128 :param is_removed: Whether the provider is being removed.
129 """
130 if self._client:
131 await self._client.disconnect()
132 self._client = None
133 self._streaming = None
134 await super().unload(is_removed)
135
136 def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping:
137 """Create a generic item mapping.
138
139 :param media_type: The media type.
140 :param key: The item ID.
141 :param name: The item name.
142 :return: An ItemMapping instance.
143 """
144 if isinstance(media_type, str):
145 media_type = MediaType(media_type)
146 return ItemMapping(
147 media_type=media_type,
148 item_id=key,
149 provider=self.instance_id,
150 name=name,
151 )
152
153 async def _fetch_my_mix_tracks(
154 self,
155 *,
156 max_tracks: int = MY_MIX_MAX_TRACKS,
157 max_batches: int = MY_MIX_BATCH_SIZE,
158 initial_queue: str | int | None = None,
159 seen_track_ids: set[str] | None = None,
160 ) -> tuple[list[Track], str | None, str | None, set[str]]:
161 """Fetch My Mix tracks with de-duplication and radio feedback.
162
163 :param max_tracks: Maximum number of tracks to return.
164 :param max_batches: Maximum number of API batch calls.
165 :param initial_queue: Optional track ID for API pagination.
166 :param seen_track_ids: Already-seen track IDs for de-duplication.
167 :return: (tracks, last_batch_id, last_first_track_id, updated_seen_ids).
168 """
169 if seen_track_ids is None:
170 seen_track_ids = set()
171
172 tracks: list[Track] = []
173 last_batch_id: str | None = None
174 last_first_track_id: str | None = None
175 queue: str | int | None = initial_queue
176
177 for _ in range(max_batches):
178 if len(tracks) >= max_tracks:
179 break
180
181 yandex_tracks, batch_id = await self.client.get_my_mix_tracks(queue=queue)
182 if batch_id:
183 self._my_mix_batch_id = batch_id
184 last_batch_id = batch_id
185 if not self._my_mix_radio_started_sent and yandex_tracks:
186 self._my_mix_radio_started_sent = True
187 await self.client.send_rotor_station_feedback(
188 ROTOR_STATION_MY_MIX,
189 "radioStarted",
190 batch_id=batch_id,
191 )
192 first_track_id_this_batch: str | None = None
193 for yt in yandex_tracks:
194 if len(tracks) >= max_tracks:
195 break
196 try:
197 t = parse_track(self, yt)
198 track_id = (
199 str(yt.id) if hasattr(yt, "id") and yt.id else getattr(yt, "track_id", None)
200 )
201 if track_id:
202 if track_id in seen_track_ids:
203 self.logger.debug("Skipping duplicate My Mix track: %s", track_id)
204 continue
205 seen_track_ids.add(track_id)
206 if first_track_id_this_batch is None:
207 first_track_id_this_batch = track_id
208 t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_MIX}"
209 for pm in t.provider_mappings:
210 if pm.provider_instance == self.instance_id:
211 pm.item_id = t.item_id
212 break
213 tracks.append(t)
214 except InvalidDataError as err:
215 self.logger.debug("Error parsing My Mix track: %s", err)
216 if first_track_id_this_batch is not None:
217 last_first_track_id = first_track_id_this_batch
218 if not batch_id or not yandex_tracks or len(tracks) >= max_tracks:
219 break
220 queue = first_track_id_this_batch
221
222 return (tracks, last_batch_id, last_first_track_id, seen_track_ids)
223
224 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
225 """Browse provider items with locale-based folder names and My Mix.
226
227 Root level shows My Mix, artists, albums, liked tracks, playlists. Names
228 are in Russian when MA locale is ru_*, otherwise in English. My Mix
229 tracks use item_id format track_id@station_id for rotor feedback.
230
231 :param path: The path to browse (e.g. provider_id:// or provider_id://artists).
232 """
233 if ProviderFeature.BROWSE not in self.supported_features:
234 raise NotImplementedError
235
236 path_parts = path.split("://")[1].split("/") if "://" in path else []
237 subpath = path_parts[0] if len(path_parts) > 0 else None
238 sub_subpath = path_parts[1] if len(path_parts) > 1 else None
239
240 if subpath == MY_MIX_PLAYLIST_ID:
241 max_batches = MY_MIX_BATCH_SIZE if sub_subpath != "next" else 1
242
243 if sub_subpath != "next":
244 self._my_mix_seen_track_ids = set()
245
246 queue: str | int | None = None
247 if sub_subpath == "next":
248 queue = self._my_mix_last_track_id
249 elif sub_subpath:
250 queue = sub_subpath
251
252 (
253 fetched,
254 last_batch_id,
255 last_first_track_id,
256 self._my_mix_seen_track_ids,
257 ) = await self._fetch_my_mix_tracks(
258 max_batches=max_batches,
259 initial_queue=queue,
260 seen_track_ids=self._my_mix_seen_track_ids,
261 )
262 if last_first_track_id is not None:
263 self._my_mix_last_track_id = last_first_track_id
264
265 all_tracks: list[Track | BrowseFolder] = list(fetched)
266
267 # Apply initial tracks limit if not in "load more" mode
268 if sub_subpath != "next":
269 if len(all_tracks) > BROWSE_INITIAL_TRACKS:
270 all_tracks = all_tracks[:BROWSE_INITIAL_TRACKS]
271
272 # Only show "Load more" if we haven't reached the limit and there's more data
273 if last_batch_id and len(fetched) < MY_MIX_MAX_TRACKS:
274 names = self._get_browse_names()
275 next_name = "ÐÑÑ" if names == BROWSE_NAMES_RU else "Load more"
276 all_tracks.append(
277 BrowseFolder(
278 item_id="next",
279 provider=self.instance_id,
280 path=f"{path.rstrip('/')}/next",
281 name=next_name,
282 is_playable=False,
283 )
284 )
285 return all_tracks
286
287 if subpath:
288 return await super().browse(path)
289
290 names = self._get_browse_names()
291
292 folders: list[BrowseFolder] = []
293 base = path if path.endswith("//") else path.rstrip("/") + "/"
294 folders.append(
295 BrowseFolder(
296 item_id=MY_MIX_PLAYLIST_ID,
297 provider=self.instance_id,
298 path=f"{base}{MY_MIX_PLAYLIST_ID}",
299 name=names[MY_MIX_PLAYLIST_ID],
300 is_playable=True,
301 )
302 )
303 if ProviderFeature.LIBRARY_ARTISTS in self.supported_features:
304 folders.append(
305 BrowseFolder(
306 item_id="artists",
307 provider=self.instance_id,
308 path=f"{base}artists",
309 name=names["artists"],
310 is_playable=True,
311 )
312 )
313 if ProviderFeature.LIBRARY_ALBUMS in self.supported_features:
314 folders.append(
315 BrowseFolder(
316 item_id="albums",
317 provider=self.instance_id,
318 path=f"{base}albums",
319 name=names["albums"],
320 is_playable=True,
321 )
322 )
323 if ProviderFeature.LIBRARY_TRACKS in self.supported_features:
324 folders.append(
325 BrowseFolder(
326 item_id="tracks",
327 provider=self.instance_id,
328 path=f"{base}tracks",
329 name=names["tracks"],
330 is_playable=True,
331 )
332 )
333 if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
334 folders.append(
335 BrowseFolder(
336 item_id="playlists",
337 provider=self.instance_id,
338 path=f"{base}playlists",
339 name=names["playlists"],
340 is_playable=True,
341 )
342 )
343 if len(folders) == 1:
344 return await self.browse(folders[0].path)
345 return folders
346
347 # Search
348
349 @use_cache(3600 * 24 * 14)
350 async def search(
351 self, search_query: str, media_types: list[MediaType], limit: int = 5
352 ) -> SearchResults:
353 """Perform search on KION Music.
354
355 :param search_query: The search query.
356 :param media_types: List of media types to search for.
357 :param limit: Maximum number of results per type.
358 :return: SearchResults with found items.
359 """
360 result = SearchResults()
361
362 # Determine search type based on requested media types
363 # Map MediaType to KION API search type
364 type_mapping = {
365 MediaType.TRACK: "track",
366 MediaType.ALBUM: "album",
367 MediaType.ARTIST: "artist",
368 MediaType.PLAYLIST: "playlist",
369 }
370 requested_types = [type_mapping[mt] for mt in media_types if mt in type_mapping]
371
372 # Use specific type if only one requested, otherwise search all
373 search_type = requested_types[0] if len(requested_types) == 1 else "all"
374
375 search_result = await self.client.search(search_query, search_type=search_type, limit=limit)
376 if not search_result:
377 return result
378
379 # Parse tracks
380 if MediaType.TRACK in media_types and search_result.tracks:
381 for track in search_result.tracks.results[:limit]:
382 try:
383 result.tracks = [*result.tracks, parse_track(self, track)]
384 except InvalidDataError as err:
385 self.logger.debug("Error parsing track: %s", err)
386
387 # Parse albums
388 if MediaType.ALBUM in media_types and search_result.albums:
389 for album in search_result.albums.results[:limit]:
390 try:
391 result.albums = [*result.albums, parse_album(self, album)]
392 except InvalidDataError as err:
393 self.logger.debug("Error parsing album: %s", err)
394
395 # Parse artists
396 if MediaType.ARTIST in media_types and search_result.artists:
397 for artist in search_result.artists.results[:limit]:
398 try:
399 result.artists = [*result.artists, parse_artist(self, artist)]
400 except InvalidDataError as err:
401 self.logger.debug("Error parsing artist: %s", err)
402
403 # Parse playlists
404 if MediaType.PLAYLIST in media_types and search_result.playlists:
405 for playlist in search_result.playlists.results[:limit]:
406 try:
407 result.playlists = [*result.playlists, parse_playlist(self, playlist)]
408 except InvalidDataError as err:
409 self.logger.debug("Error parsing playlist: %s", err)
410
411 return result
412
413 # Get single items
414
415 @use_cache(3600 * 24 * 30)
416 async def get_artist(self, prov_artist_id: str) -> Artist:
417 """Get artist details by ID.
418
419 :param prov_artist_id: The provider artist ID.
420 :return: Artist object.
421 :raises MediaNotFoundError: If artist not found.
422 """
423 artist = await self.client.get_artist(prov_artist_id)
424 if not artist:
425 raise MediaNotFoundError(f"Artist {prov_artist_id} not found")
426 return parse_artist(self, artist)
427
428 @use_cache(3600 * 24 * 30)
429 async def get_album(self, prov_album_id: str) -> Album:
430 """Get album details by ID.
431
432 :param prov_album_id: The provider album ID.
433 :return: Album object.
434 :raises MediaNotFoundError: If album not found.
435 """
436 album = await self.client.get_album(prov_album_id)
437 if not album:
438 raise MediaNotFoundError(f"Album {prov_album_id} not found")
439 return parse_album(self, album)
440
441 async def get_track(self, prov_track_id: str) -> Track:
442 """Get track details by ID.
443
444 Supports composite item_id (track_id@station_id) for My Mix tracks;
445 only the track_id part is used for the API. Normalizes the ID before
446 caching so that "12345" and "12345@user:onyourwave" share one cache entry.
447
448 :param prov_track_id: The provider track ID (or track_id@station_id).
449 :return: Track object.
450 :raises MediaNotFoundError: If track not found.
451 """
452 track_id, _ = _parse_radio_item_id(prov_track_id)
453 return await self._get_track_cached(track_id)
454
455 @use_cache(3600 * 24 * 30)
456 async def _get_track_cached(self, track_id: str) -> Track:
457 """Fetch and cache track details by normalized track ID.
458
459 :param track_id: Plain track ID (no station suffix).
460 :return: Track object.
461 :raises MediaNotFoundError: If track not found.
462 """
463 yandex_track = await self.client.get_track(track_id)
464 if not yandex_track:
465 raise MediaNotFoundError(f"Track {track_id} not found")
466 return parse_track(self, yandex_track)
467
468 @use_cache(3600 * 24 * 30)
469 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
470 """Get playlist details by ID.
471
472 Supports virtual playlist MY_MIX_PLAYLIST_ID (My Mix). Real playlists
473 use format "owner_id:kind".
474
475 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind" or my_mix).
476 :return: Playlist object.
477 :raises MediaNotFoundError: If playlist not found.
478 """
479 if prov_playlist_id == MY_MIX_PLAYLIST_ID:
480 names = self._get_browse_names()
481 return Playlist(
482 item_id=MY_MIX_PLAYLIST_ID,
483 provider=self.instance_id,
484 name=names[MY_MIX_PLAYLIST_ID],
485 owner="KION Music",
486 provider_mappings={
487 ProviderMapping(
488 item_id=MY_MIX_PLAYLIST_ID,
489 provider_domain=self.domain,
490 provider_instance=self.instance_id,
491 is_unique=True,
492 )
493 },
494 is_editable=False,
495 )
496
497 # Parse the playlist ID (format: owner_id:kind)
498 if PLAYLIST_ID_SPLITTER in prov_playlist_id:
499 owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
500 else:
501 owner_id = str(self.client.user_id)
502 kind = prov_playlist_id
503
504 playlist = await self.client.get_playlist(owner_id, kind)
505 if not playlist:
506 raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found")
507 return parse_playlist(self, playlist)
508
509 async def _get_my_mix_playlist_tracks(self, page: int) -> list[Track]:
510 """Get My Mix tracks for virtual playlist (uncached; uses cursor for page > 0).
511
512 :param page: Page number (0 = first batch, 1+ = next batches via queue cursor).
513 :return: List of Track objects for this page.
514 """
515 if page == 0:
516 self._my_mix_seen_track_ids = set()
517
518 queue: str | int | None = None
519 if page > 0:
520 queue = self._my_mix_playlist_next_cursor
521 if not queue:
522 return []
523
524 if len(self._my_mix_seen_track_ids) >= MY_MIX_MAX_TRACKS:
525 return []
526
527 (
528 tracks,
529 _,
530 last_first_track_id,
531 self._my_mix_seen_track_ids,
532 ) = await self._fetch_my_mix_tracks(
533 max_batches=1,
534 initial_queue=queue,
535 seen_track_ids=self._my_mix_seen_track_ids,
536 )
537 if last_first_track_id is not None:
538 self._my_mix_playlist_next_cursor = last_first_track_id
539 return tracks
540
541 # Get related items
542
543 @use_cache(3600 * 24 * 30)
544 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
545 """Get album tracks.
546
547 :param prov_album_id: The provider album ID.
548 :return: List of Track objects.
549 """
550 album = await self.client.get_album_with_tracks(prov_album_id)
551 if not album or not album.volumes:
552 return []
553
554 tracks = []
555 for volume_index, volume in enumerate(album.volumes):
556 for track_index, track in enumerate(volume):
557 try:
558 parsed_track = parse_track(self, track)
559 parsed_track.disc_number = volume_index + 1
560 parsed_track.track_number = track_index + 1
561 tracks.append(parsed_track)
562 except InvalidDataError as err:
563 self.logger.debug("Error parsing album track: %s", err)
564 return tracks
565
566 @use_cache(3600 * 3)
567 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
568 """Get similar tracks using rotor station for this track.
569
570 Uses rotor station track:{id} so MA radio mode gets recommendations.
571
572 :param prov_track_id: Provider track ID (plain or track_id@station_id).
573 :param limit: Maximum number of tracks to return.
574 :return: List of similar Track objects.
575 """
576 track_id, _ = _parse_radio_item_id(prov_track_id)
577 station_id = f"track:{track_id}"
578 yandex_tracks, _ = await self.client.get_rotor_station_tracks(station_id, queue=None)
579 tracks = []
580 for yt in yandex_tracks[:limit]:
581 try:
582 tracks.append(parse_track(self, yt))
583 except InvalidDataError as err:
584 self.logger.debug("Error parsing similar track: %s", err)
585 return tracks
586
587 @use_cache(600) # Cache for 10 minutes
588 async def recommendations(self) -> list[RecommendationFolder]:
589 """Get recommendations; includes My Mix (Ðой ÐикÑ) as first folder.
590
591 Fetches fresh tracks on each call for discovery experience.
592
593 :return: List of recommendation folders (My Mix with tracks).
594 """
595 items, _, _, _ = await self._fetch_my_mix_tracks(
596 max_tracks=DISCOVERY_INITIAL_TRACKS,
597 )
598
599 names = self._get_browse_names()
600 return [
601 RecommendationFolder(
602 item_id=MY_MIX_PLAYLIST_ID,
603 provider=self.instance_id,
604 name=names[MY_MIX_PLAYLIST_ID],
605 items=UniqueList(items),
606 icon="mdi-waveform",
607 )
608 ]
609
610 @use_cache(3600 * 3)
611 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
612 """Get playlist tracks.
613
614 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind" or my_mix).
615 :param page: Page number for pagination.
616 :return: List of Track objects.
617 """
618 if prov_playlist_id == MY_MIX_PLAYLIST_ID:
619 return await self._get_my_mix_playlist_tracks(page)
620
621 # KION Music API returns all playlist tracks in one call (no server-side pagination).
622 # Return empty list for page > 0 so the controller pagination loop terminates.
623 if page > 0:
624 return []
625
626 # Parse the playlist ID (format: owner_id:kind)
627 if PLAYLIST_ID_SPLITTER in prov_playlist_id:
628 owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
629 else:
630 owner_id = str(self.client.user_id)
631 kind = prov_playlist_id
632
633 playlist = await self.client.get_playlist(owner_id, kind)
634 if not playlist:
635 return []
636
637 # API sometimes returns playlist without tracks; fetch them explicitly if needed
638 tracks_list = playlist.tracks or []
639 track_count = getattr(playlist, "track_count", None) or 0
640 if not tracks_list and track_count > 0:
641 self.logger.debug(
642 "Playlist %s/%s: track_count=%s but no tracks in response, "
643 "calling fetch_tracks_async",
644 owner_id,
645 kind,
646 track_count,
647 )
648 try:
649 tracks_list = await playlist.fetch_tracks_async()
650 except Exception as err:
651 self.logger.warning("fetch_tracks_async failed for %s/%s: %s", owner_id, kind, err)
652 if not tracks_list:
653 raise ResourceTemporarilyUnavailable(
654 "Playlist tracks not available; try again later"
655 )
656
657 if not tracks_list:
658 return []
659
660 # API returns TrackShort objects, we need to fetch full track info
661 track_ids = [
662 str(track.track_id) if hasattr(track, "track_id") else str(track.id)
663 for track in tracks_list
664 if track
665 ]
666 if not track_ids:
667 return []
668
669 # Fetch full track details in batches to avoid timeouts
670 full_tracks = []
671 for i in range(0, len(track_ids), TRACK_BATCH_SIZE):
672 batch = track_ids[i : i + TRACK_BATCH_SIZE]
673 batch_result = await self.client.get_tracks(batch)
674 if not batch_result:
675 self.logger.warning(
676 "Received empty result for playlist %s tracks batch %s-%s",
677 prov_playlist_id,
678 i,
679 i + len(batch) - 1,
680 )
681 raise ResourceTemporarilyUnavailable(
682 "Playlist tracks not fully available; try again later"
683 )
684 full_tracks.extend(batch_result)
685
686 if track_ids and not full_tracks:
687 raise ResourceTemporarilyUnavailable("Failed to load track details; try again later")
688
689 tracks = []
690 for track in full_tracks:
691 try:
692 tracks.append(parse_track(self, track))
693 except InvalidDataError as err:
694 self.logger.debug("Error parsing playlist track: %s", err)
695 return tracks
696
697 @use_cache(3600 * 24 * 7)
698 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
699 """Get artist's albums.
700
701 :param prov_artist_id: The provider artist ID.
702 :return: List of Album objects.
703 """
704 albums = await self.client.get_artist_albums(prov_artist_id)
705 result = []
706 for album in albums:
707 try:
708 result.append(parse_album(self, album))
709 except InvalidDataError as err:
710 self.logger.debug("Error parsing artist album: %s", err)
711 return result
712
713 @use_cache(3600 * 24 * 7)
714 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
715 """Get artist's top tracks.
716
717 :param prov_artist_id: The provider artist ID.
718 :return: List of Track objects.
719 """
720 tracks = await self.client.get_artist_tracks(prov_artist_id)
721 result = []
722 for track in tracks:
723 try:
724 result.append(parse_track(self, track))
725 except InvalidDataError as err:
726 self.logger.debug("Error parsing artist track: %s", err)
727 return result
728
729 # Library methods
730
731 async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
732 """Retrieve library artists from KION Music."""
733 artists = await self.client.get_liked_artists()
734 for artist in artists:
735 try:
736 yield parse_artist(self, artist)
737 except InvalidDataError as err:
738 self.logger.debug("Error parsing library artist: %s", err)
739
740 async def get_library_albums(self) -> AsyncGenerator[Album, None]:
741 """Retrieve library albums from KION Music."""
742 albums = await self.client.get_liked_albums(batch_size=TRACK_BATCH_SIZE)
743 for album in albums:
744 try:
745 yield parse_album(self, album)
746 except InvalidDataError as err:
747 self.logger.debug("Error parsing library album: %s", err)
748
749 async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
750 """Retrieve library tracks from KION Music."""
751 track_shorts = await self.client.get_liked_tracks()
752 if not track_shorts:
753 return
754
755 # Fetch full track details in batches
756 track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id]
757 for i in range(0, len(track_ids), TRACK_BATCH_SIZE):
758 batch_ids = track_ids[i : i + TRACK_BATCH_SIZE]
759 full_tracks = await self.client.get_tracks(batch_ids)
760 for track in full_tracks:
761 try:
762 yield parse_track(self, track)
763 except InvalidDataError as err:
764 self.logger.debug("Error parsing library track: %s", err)
765
766 async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
767 """Retrieve library playlists from KION Music.
768
769 Includes the virtual My Mix playlist first, then user playlists.
770 """
771 yield await self.get_playlist(MY_MIX_PLAYLIST_ID)
772 playlists = await self.client.get_user_playlists()
773 for playlist in playlists:
774 try:
775 yield parse_playlist(self, playlist)
776 except InvalidDataError as err:
777 self.logger.debug("Error parsing library playlist: %s", err)
778
779 # Library edit methods
780
781 async def library_add(self, item: MediaItemType) -> bool:
782 """Add item to library.
783
784 :param item: The media item to add.
785 :return: True if successful.
786 """
787 prov_item_id = self._get_provider_item_id(item)
788 if not prov_item_id:
789 return False
790 track_id, _ = _parse_radio_item_id(prov_item_id)
791
792 if item.media_type == MediaType.TRACK:
793 return await self.client.like_track(track_id)
794 if item.media_type == MediaType.ALBUM:
795 return await self.client.like_album(prov_item_id)
796 if item.media_type == MediaType.ARTIST:
797 return await self.client.like_artist(prov_item_id)
798 return False
799
800 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
801 """Remove item from library.
802
803 :param prov_item_id: The provider item ID (may be track_id@station_id for tracks).
804 :param media_type: The media type.
805 :return: True if successful.
806 """
807 track_id, _ = _parse_radio_item_id(prov_item_id)
808 if media_type == MediaType.TRACK:
809 return await self.client.unlike_track(track_id)
810 if media_type == MediaType.ALBUM:
811 return await self.client.unlike_album(prov_item_id)
812 if media_type == MediaType.ARTIST:
813 return await self.client.unlike_artist(prov_item_id)
814 return False
815
816 def _get_provider_item_id(self, item: MediaItemType) -> str | None:
817 """Get provider item ID from media item."""
818 for mapping in item.provider_mappings:
819 if mapping.provider_instance == self.instance_id:
820 return mapping.item_id
821 return item.item_id if item.provider == self.instance_id else None
822
823 # Streaming
824
825 async def get_stream_details(
826 self, item_id: str, media_type: MediaType = MediaType.TRACK
827 ) -> StreamDetails:
828 """Get stream details for a track.
829
830 :param item_id: The track ID (or track_id@station_id for My Mix).
831 :param media_type: The media type (should be TRACK).
832 :return: StreamDetails for the track.
833 """
834 return await self.streaming.get_stream_details(item_id)
835
836 async def on_played(
837 self,
838 media_type: MediaType,
839 prov_item_id: str,
840 fully_played: bool,
841 position: int,
842 media_item: MediaItemType,
843 is_playing: bool = False,
844 ) -> None:
845 """Report playback for rotor feedback when the track is from My Mix.
846
847 Sends trackStarted when the track is currently playing (is_playing=True).
848 trackFinished/skip are sent from on_streamed to use accurate seconds_streamed.
849 """
850 if media_type != MediaType.TRACK:
851 return
852 track_id, station_id = _parse_radio_item_id(prov_item_id)
853 if not station_id:
854 return
855 if is_playing:
856 await self.client.send_rotor_station_feedback(
857 station_id,
858 "trackStarted",
859 track_id=track_id,
860 batch_id=self._my_mix_batch_id,
861 )
862
863 async def on_streamed(self, streamdetails: StreamDetails) -> None:
864 """Report stream completion for My Mix rotor feedback.
865
866 Sends trackFinished or skip with actual seconds_streamed so the service
867 can improve recommendations.
868 """
869 track_id, station_id = _parse_radio_item_id(streamdetails.item_id)
870 if not station_id:
871 return
872 seconds = int(streamdetails.seconds_streamed or 0)
873 duration = streamdetails.duration or 0
874 feedback_type = "trackFinished" if duration and seconds >= max(0, duration - 10) else "skip"
875 await self.client.send_rotor_station_feedback(
876 station_id,
877 feedback_type,
878 track_id=track_id,
879 total_played_seconds=seconds,
880 batch_id=self._my_mix_batch_id,
881 )
882