/
/
/
1"""Zvuk Music provider implementation."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, Any
6from urllib.parse import urlparse
7
8import aiohttp
9from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
10from music_assistant_models.enums import (
11 ConfigEntryType,
12 ContentType,
13 MediaType,
14 ProviderFeature,
15 StreamType,
16)
17from music_assistant_models.errors import (
18 InvalidDataError,
19 LoginFailed,
20 MediaNotFoundError,
21 ProviderUnavailableError,
22 ResourceTemporarilyUnavailable,
23)
24from music_assistant_models.media_items import (
25 Album,
26 Artist,
27 AudioFormat,
28 BrowseFolder,
29 ItemMapping,
30 MediaItemMetadata,
31 MediaItemType,
32 Playlist,
33 RecommendationFolder,
34 SearchResults,
35 Track,
36 UniqueList,
37)
38from music_assistant_models.streamdetails import StreamDetails
39
40from music_assistant.constants import CONF_ENTRY_UNOFFICIAL_PROVIDER
41from music_assistant.controllers.cache import use_cache
42from music_assistant.models.music_provider import MusicProvider
43
44from .api_client import ZvukMusicClient
45from .constants import (
46 CONF_QUALITY,
47 CONF_TOKEN,
48 DEFAULT_LIMIT,
49 PLAYLIST_TRACK_FETCH_LIMIT,
50 PLAYLIST_TRACKS_PAGE_SIZE,
51 QUALITY_HIGH,
52 QUALITY_LOSSLESS,
53 SYNTHESIS_PLAYLIST_IDS,
54)
55from .parsers import parse_album, parse_artist, parse_playlist, parse_track
56
57if TYPE_CHECKING:
58 from collections.abc import AsyncGenerator
59
60
61class ZvukMusicProvider(MusicProvider):
62 """Implementation of a Zvuk Music MusicProvider."""
63
64 _client: ZvukMusicClient | None = None
65
66 @property
67 def client(self) -> ZvukMusicClient:
68 """Return the Zvuk Music client."""
69 if self._client is None:
70 raise ProviderUnavailableError("Provider not initialized")
71 return self._client
72
73 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
74 """Return Config entries to configure this provider."""
75 return (
76 CONF_ENTRY_UNOFFICIAL_PROVIDER,
77 ConfigEntry(
78 key=CONF_QUALITY,
79 type=ConfigEntryType.STRING,
80 options=[
81 ConfigValueOption(QUALITY_HIGH),
82 ConfigValueOption(QUALITY_LOSSLESS),
83 ],
84 default_value=QUALITY_HIGH,
85 ),
86 )
87
88 async def handle_async_init(self) -> None:
89 """Handle async initialization of the provider."""
90 token = self.get_setup_value(CONF_TOKEN)
91 if not token:
92 raise LoginFailed("No Zvuk Music token provided")
93
94 self._client = ZvukMusicClient(str(token))
95 await self._client.connect()
96 self.logger.info("Successfully connected to Zvuk Music")
97
98 async def unload(self, is_removed: bool = False) -> None:
99 """
100 Handle unload/close of the provider.
101
102 :param is_removed: Whether the provider is being removed.
103 """
104 if self._client:
105 await self._client.disconnect()
106 self._client = None
107 await super().unload(is_removed)
108
109 def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping:
110 """
111 Create a generic item mapping.
112
113 :param media_type: The media type.
114 :param key: The item ID.
115 :param name: The item name.
116 :return: An ItemMapping instance.
117 """
118 if isinstance(media_type, str):
119 media_type = MediaType(media_type)
120 return ItemMapping(
121 media_type=media_type,
122 item_id=key,
123 provider=self.instance_id,
124 name=name,
125 )
126
127 # Search
128
129 @use_cache(3600 * 24 * 14)
130 async def search(
131 self, search_query: str, media_types: list[MediaType], limit: int = 5
132 ) -> SearchResults:
133 """
134 Perform search on Zvuk Music.
135
136 :param search_query: The search query.
137 :param media_types: List of media types to search for.
138 :param limit: Maximum number of results per type.
139 :return: SearchResults with found items.
140 """
141 result = SearchResults()
142
143 search_result = await self.client.search(
144 search_query,
145 limit=limit,
146 search_tracks=MediaType.TRACK in media_types,
147 search_artists=MediaType.ARTIST in media_types,
148 search_releases=MediaType.ALBUM in media_types,
149 search_playlists=MediaType.PLAYLIST in media_types,
150 )
151 if not search_result:
152 return result
153
154 # Parse tracks
155 if MediaType.TRACK in media_types and search_result.tracks:
156 for track in search_result.tracks.items[:limit]:
157 try:
158 result.tracks = [*result.tracks, parse_track(self, track)]
159 except InvalidDataError as err:
160 self.logger.debug("Error parsing track: %s", err)
161
162 # Parse albums (Zvuk releases)
163 if MediaType.ALBUM in media_types and search_result.releases:
164 for release in search_result.releases.items[:limit]:
165 try:
166 result.albums = [*result.albums, parse_album(self, release)]
167 except InvalidDataError as err:
168 self.logger.debug("Error parsing album: %s", err)
169
170 # Parse artists
171 if MediaType.ARTIST in media_types and search_result.artists:
172 for artist in search_result.artists.items[:limit]:
173 try:
174 result.artists = [*result.artists, parse_artist(self, artist)]
175 except InvalidDataError as err:
176 self.logger.debug("Error parsing artist: %s", err)
177
178 # Parse playlists
179 if MediaType.PLAYLIST in media_types and search_result.playlists:
180 for playlist in search_result.playlists.items[:limit]:
181 try:
182 result.playlists = [*result.playlists, parse_playlist(self, playlist)]
183 except InvalidDataError as err:
184 self.logger.debug("Error parsing playlist: %s", err)
185
186 return result
187
188 # Get single items
189
190 @use_cache(3600 * 24 * 30)
191 async def get_artist(self, prov_artist_id: str) -> Artist:
192 """
193 Get artist details by ID.
194
195 :param prov_artist_id: The provider artist ID.
196 :return: Artist object.
197 :raises MediaNotFoundError: If artist not found.
198 """
199 artist = await self.client.get_artist(prov_artist_id)
200 if not artist:
201 raise MediaNotFoundError(f"Artist {prov_artist_id} not found")
202 return parse_artist(self, artist)
203
204 @use_cache(3600 * 24 * 30)
205 async def get_album(self, prov_album_id: str) -> Album:
206 """
207 Get album details by ID.
208
209 :param prov_album_id: The provider album ID.
210 :return: Album object.
211 :raises MediaNotFoundError: If album not found.
212 """
213 release = await self.client.get_release(prov_album_id)
214 if not release:
215 raise MediaNotFoundError(f"Album {prov_album_id} not found")
216 return parse_album(self, release)
217
218 @use_cache(3600 * 24 * 30)
219 async def get_track(self, prov_track_id: str) -> Track:
220 """
221 Get track details by ID.
222
223 :param prov_track_id: The provider track ID.
224 :return: Track object.
225 :raises MediaNotFoundError: If track not found.
226 """
227 track = await self.client.get_track(prov_track_id)
228 if not track:
229 raise MediaNotFoundError(f"Track {prov_track_id} not found")
230 return parse_track(self, track)
231
232 @use_cache(3600 * 24 * 30)
233 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
234 """
235 Get playlist details by ID.
236
237 :param prov_playlist_id: The provider playlist ID.
238 :return: Playlist object.
239 :raises MediaNotFoundError: If playlist not found.
240 """
241 playlist = await self.client.get_playlist(prov_playlist_id)
242 if not playlist:
243 raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found")
244 return parse_playlist(self, playlist)
245
246 # Get related items
247
248 @use_cache(3600 * 24 * 30, allow_expired_cache=True)
249 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
250 """
251 Get album tracks.
252
253 :param prov_album_id: The provider album ID.
254 :return: List of Track objects.
255 """
256 release = await self.client.get_release(prov_album_id)
257 if not release or not release.tracks:
258 return []
259
260 tracks = []
261 for index, track in enumerate(release.tracks):
262 try:
263 parsed_track = parse_track(self, track)
264 parsed_track.disc_number = 1
265 parsed_track.track_number = index + 1
266 tracks.append(parsed_track)
267 except InvalidDataError as err:
268 self.logger.debug("Error parsing album track: %s", err)
269 return tracks
270
271 @use_cache(3600 * 3, allow_expired_cache=True)
272 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
273 """
274 Get playlist tracks.
275
276 :param prov_playlist_id: The provider playlist ID.
277 :param page: Page number for pagination.
278 :return: List of Track objects.
279 """
280 offset = page * PLAYLIST_TRACKS_PAGE_SIZE
281 simple_tracks = await self.client.get_playlist_tracks(
282 prov_playlist_id, limit=PLAYLIST_TRACKS_PAGE_SIZE, offset=offset
283 )
284 if not simple_tracks:
285 return []
286
287 # Fetch full track details from SimpleTrack IDs
288 track_ids = [str(t.id) for t in simple_tracks if t.id]
289 if not track_ids:
290 return []
291
292 full_tracks = await self.client.get_tracks(track_ids)
293 tracks = []
294 for track in full_tracks:
295 try:
296 tracks.append(parse_track(self, track))
297 except InvalidDataError as err:
298 self.logger.debug("Error parsing playlist track: %s", err)
299 return tracks
300
301 @use_cache(3600 * 24 * 7, allow_expired_cache=True)
302 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
303 """
304 Get artist's albums.
305
306 :param prov_artist_id: The provider artist ID.
307 :return: List of Album objects.
308 """
309 artists = await self.client.get_artist_releases(prov_artist_id, limit=DEFAULT_LIMIT)
310 if not artists:
311 return []
312
313 result = []
314 for artist in artists:
315 for release in artist.releases:
316 try:
317 result.append(parse_album(self, release))
318 except InvalidDataError as err:
319 self.logger.debug("Error parsing artist album: %s", err)
320 return result
321
322 @use_cache(3600 * 24 * 7, allow_expired_cache=True)
323 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
324 """
325 Get artist's top tracks.
326
327 :param prov_artist_id: The provider artist ID.
328 :return: List of Track objects.
329 """
330 artists = await self.client.get_artist_top_tracks(prov_artist_id, limit=DEFAULT_LIMIT)
331 if not artists:
332 return []
333
334 result = []
335 for artist in artists:
336 for track in artist.popular_tracks:
337 try:
338 result.append(parse_track(self, track))
339 except InvalidDataError as err:
340 self.logger.debug("Error parsing artist track: %s", err)
341 return result
342
343 @use_cache(3600 * 24 * 7, allow_expired_cache=True)
344 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
345 """
346 Get similar tracks based on related releases of the track's album.
347
348 Uses the Zvuk ``release.related`` field to find similar releases and samples tracks from
349 them. Only called if provider supports ProviderFeature.SIMILAR_TRACKS.
350
351 :param prov_track_id: The provider track ID.
352 :param limit: Maximum number of similar tracks to return.
353 :return: List of Track objects.
354 """
355 track = await self.client.get_track(prov_track_id)
356 if not track or not track.release:
357 return []
358
359 release = await self.client.get_release(str(track.release.id))
360 if not release or not getattr(release, "related", None):
361 return []
362
363 result: list[Track] = []
364 for related_release in release.related:
365 if len(result) >= limit:
366 break
367 if not getattr(related_release, "id", None):
368 continue
369 related_full = await self.client.get_release(str(related_release.id))
370 if not related_full or not related_full.tracks:
371 continue
372 for t in related_full.tracks[:2]:
373 try:
374 result.append(parse_track(self, t))
375 except InvalidDataError as err:
376 self.logger.debug("Error parsing similar track: %s", err)
377 if len(result) >= limit:
378 break
379
380 return result[:limit]
381
382 # Library methods
383
384 async def _iter_batched(
385 self,
386 ids: list[str],
387 fetcher: Any,
388 parser: Any,
389 item_type: str,
390 ) -> AsyncGenerator[Any]:
391 """
392 Yield parsed items by fetching ``ids`` in batches of DEFAULT_LIMIT.
393
394 :param ids: List of item IDs to fetch.
395 :param fetcher: Async callable that accepts a list of IDs and returns a list of raw items.
396 :param parser: Callable(provider, raw_item) â MA media item.
397 :param item_type: Human-readable type name for debug log messages.
398 """
399 for i in range(0, len(ids), DEFAULT_LIMIT):
400 batch = ids[i : i + DEFAULT_LIMIT]
401 items = await fetcher(batch)
402 for item in items:
403 try:
404 yield parser(self, item)
405 except InvalidDataError as err:
406 self.logger.debug("Error parsing library %s: %s", item_type, err)
407
408 async def get_library_artists(self) -> AsyncGenerator[Artist]:
409 """Retrieve library artists from Zvuk Music."""
410 collection = await self.client.get_collection()
411 if not collection or not collection.artists:
412 return
413 ids = [str(item.id) for item in collection.artists if item.id]
414 async for artist in self._iter_batched(
415 ids, self.client.get_artists, parse_artist, "artist"
416 ):
417 yield artist
418
419 async def get_library_albums(self) -> AsyncGenerator[Album]:
420 """Retrieve library albums from Zvuk Music."""
421 collection = await self.client.get_collection()
422 if not collection or not collection.releases:
423 return
424 ids = [str(item.id) for item in collection.releases if item.id]
425 async for album in self._iter_batched(ids, self.client.get_releases, parse_album, "album"):
426 yield album
427
428 async def get_library_tracks(self) -> AsyncGenerator[Track]:
429 """Retrieve library tracks from Zvuk Music."""
430 collection = await self.client.get_collection()
431 if not collection or not collection.tracks:
432 return
433 ids = [str(item.id) for item in collection.tracks if item.id]
434 async for track in self._iter_batched(ids, self.client.get_tracks, parse_track, "track"):
435 yield track
436
437 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
438 """
439 Retrieve library playlists from Zvuk Music.
440
441 Yields user's own playlists followed by Zvuk's personalized synthesis
442 playlists («ÐлейлиÑÑÑ Ð´Ð»Ñ Ð²Ð°Ñ»: IDs 3, 4, 6, 11, 12, 13, 14, 15).
443 """
444 collection_items = await self.client.get_user_playlists()
445 if collection_items:
446 ids = [str(item.id) for item in collection_items if item.id]
447 async for playlist in self._iter_batched(
448 ids, self.client.get_playlists, parse_playlist, "playlist"
449 ):
450 yield playlist
451
452 # Synthesis playlists â personalized AI playlists («ÐлейлиÑÑÑ Ð´Ð»Ñ Ð²Ð°Ñ»)
453 synthesis_playlists = await self.client.get_short_playlists(SYNTHESIS_PLAYLIST_IDS)
454 for simple_pl in synthesis_playlists:
455 try:
456 yield parse_playlist(self, simple_pl)
457 except InvalidDataError as err:
458 self.logger.debug("Error parsing synthesis playlist: %s", err)
459
460 async def _get_for_you_playlists(self) -> list[Playlist]:
461 """Fetch and parse Zvuk's personalized synthesis playlists («ÐлейлиÑÑÑ Ð´Ð»Ñ Ð²Ð°Ñ»)."""
462 synthesis_playlists = await self.client.get_short_playlists(SYNTHESIS_PLAYLIST_IDS)
463 result: list[Playlist] = []
464 for simple_pl in synthesis_playlists:
465 try:
466 result.append(parse_playlist(self, simple_pl))
467 except InvalidDataError as err:
468 self.logger.debug("Error parsing synthesis playlist: %s", err)
469 return result
470
471 async def _get_editorial_playlists(self) -> list[Playlist]:
472 """Fetch and parse Zvuk's editorial curated playlists («ÐодбоÑки»)."""
473 editorial_ids = await self.client.get_editorial_playlist_ids()
474 if not editorial_ids:
475 return []
476 full_playlists = await self.client.get_playlists(editorial_ids[:DEFAULT_LIMIT])
477 result: list[Playlist] = []
478 for full_pl in full_playlists:
479 try:
480 result.append(parse_playlist(self, full_pl))
481 except InvalidDataError as err:
482 self.logger.debug("Error parsing editorial playlist: %s", err)
483 return result
484
485 async def get_recommendations(self) -> list[RecommendationFolder]:
486 """
487 Return the available recommendation rows, without items.
488
489 Two rows:
490 - "for_you" ("Made for you"): Zvuk's AI-generated personalized playlists.
491 - "editorial" ("Collections"): Editorial genre-themed curated playlists.
492 """
493 return [
494 RecommendationFolder(
495 item_id="for_you",
496 provider=self.instance_id,
497 name="Made for you",
498 translation_key="made_for_you",
499 icon="mdi-playlist-music",
500 ),
501 RecommendationFolder(
502 item_id="editorial",
503 provider=self.instance_id,
504 name="Collections",
505 subtitle="Editorial playlists from Zvuk by genre",
506 translation_key="editorial",
507 icon="mdi-music-box-multiple",
508 ),
509 ]
510
511 async def get_recommendation_items(
512 self, item_id: str
513 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
514 """
515 Return the items for a single recommendation row.
516
517 :param item_id: The item_id of the row, as returned by get_recommendations.
518 """
519 if item_id == "for_you":
520 return UniqueList(await self._get_for_you_playlists())
521 if item_id == "editorial":
522 return UniqueList(await self._get_editorial_playlists())
523 return UniqueList()
524
525 async def browse(self, path: str) -> list[MediaItemType | ItemMapping | BrowseFolder]:
526 """
527 Browse provider content as a hierarchical folder tree.
528
529 Root level exposes two folders:
530 - "Made for you": Zvuk AI-generated personalized playlists.
531 - "Collections": Editorial genre-themed curated playlists.
532
533 :param path: Browse path (e.g. ``provider_id://`` or ``provider_id://for_you``).
534 :return: List of playlists or BrowseFolders.
535 """
536 if ProviderFeature.BROWSE not in self.supported_features:
537 raise NotImplementedError
538
539 subpath: str | None = None
540 if "://" in path:
541 after_scheme = path.split("://", 1)[1].strip("/")
542 if after_scheme:
543 subpath = after_scheme.split("/", 1)[0]
544
545 if subpath == "for_you":
546 return list(await self._get_for_you_playlists())
547
548 if subpath == "editorial":
549 return list(await self._get_editorial_playlists())
550
551 if subpath is not None:
552 raise KeyError(f"Unsupported browse path: {path}")
553
554 # Root level â return top-level folders
555 base = f"{self.instance_id}://"
556 return [
557 BrowseFolder(
558 item_id="for_you",
559 provider=self.instance_id,
560 path=f"{base}for_you",
561 name="Made for you",
562 translation_key="made_for_you",
563 ),
564 BrowseFolder(
565 item_id="editorial",
566 provider=self.instance_id,
567 path=f"{base}editorial",
568 name="Collections",
569 translation_key="editorial",
570 ),
571 ]
572
573 async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None:
574 """
575 Fetch lyrics for a track from Zvuk's lyrics API.
576
577 Called by MA when ``ProviderFeature.TRACK_METADATA`` is declared.
578 Returns LRC-synced lyrics (``lrc_lyrics``) when the API returns type
579 ``'subtitle'``, otherwise plain text (``lyrics``). Returns ``None`` if
580 the track has no lyrics. Any API errors are caught and return ``None``
581 so that optional metadata enrichment never fails the broader media load.
582
583 :param track: The MA Track object. ``item_id`` is used to call the API.
584 :return: MediaItemMetadata with lyrics, or None.
585 """
586 track_id = track.item_id
587 try:
588 result = await self.client.get_lyrics(track_id)
589 except (ResourceTemporarilyUnavailable, ProviderUnavailableError, LoginFailed) as err:
590 self.logger.debug("Failed to fetch lyrics for track %s: %s", track_id, err)
591 return None
592 except Exception as err:
593 self.logger.debug(
594 "Unexpected error while fetching lyrics for track %s: %s", track_id, err
595 )
596 return None
597 if not result or not result.lyrics:
598 return None
599
600 metadata = MediaItemMetadata()
601 if result.is_synced:
602 metadata.lrc_lyrics = result.lyrics
603 else:
604 metadata.lyrics = result.lyrics
605 return metadata
606
607 async def resolve_image(self, path: str) -> str | bytes:
608 """
609 Fetch a Zvuk image with optional authentication.
610
611 Called by MA when a ``MediaItemImage`` has ``remotely_accessible=False``.
612 Static playlist avatar images (``/static/avatar/playlist/...``) require
613 Zvuk auth cookies and cannot be fetched anonymously.
614
615 Auth token is only sent to trusted Zvuk domains (``zvuk.com``,
616 ``cdn.zvuk.com``, ``cdn-image.zvuk.com``) to prevent token leakage.
617 Images from Sbercloud CDN (``*.sbercloud.ru``) are fetched without auth
618 as they are publicly accessible.
619
620 :param path: Full image URL.
621 :return: Raw image bytes on success, original URL string as fallback.
622 """
623 _zvuk_auth_hosts = frozenset({"zvuk.com", "cdn.zvuk.com", "cdn-image.zvuk.com"})
624 parsed = urlparse(path)
625 hostname = parsed.hostname or ""
626 is_zvuk = hostname in _zvuk_auth_hosts
627 is_sbercloud = hostname.endswith(".sbercloud.ru")
628 if not is_zvuk and not is_sbercloud:
629 self.logger.warning("Refusing to fetch image from untrusted host: %s", hostname)
630 return str(path)
631 headers: dict[str, str] = {
632 "User-Agent": (
633 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
634 "AppleWebKit/537.36 (KHTML, like Gecko) "
635 "Chrome/120.0.0.0 Safari/537.36"
636 ),
637 }
638 if is_zvuk:
639 token = self.get_setup_value(CONF_TOKEN)
640 if not token:
641 return str(path)
642 headers["X-Auth-Token"] = str(token)
643 headers["Referer"] = "https://zvuk.com/"
644 headers["Origin"] = "https://zvuk.com"
645 try:
646 async with self.mass.http_session.get(path, headers=headers) as resp:
647 if resp.status == 200:
648 return bytes(await resp.read())
649 except (aiohttp.ClientError, TimeoutError) as err:
650 self.logger.debug("Failed to resolve image %s: %s", path, err)
651 return str(path)
652
653 # Library edit methods
654
655 async def library_add(self, item: MediaItemType) -> bool:
656 """
657 Add item to library.
658
659 :param item: The media item to add.
660 :return: True if successful.
661 """
662 prov_item_id = self._get_provider_item_id(item)
663 if not prov_item_id:
664 return False
665
666 if item.media_type == MediaType.TRACK:
667 return await self.client.like_track(prov_item_id)
668 if item.media_type == MediaType.ALBUM:
669 return await self.client.like_release(prov_item_id)
670 if item.media_type == MediaType.ARTIST:
671 return await self.client.like_artist(prov_item_id)
672 if item.media_type == MediaType.PLAYLIST:
673 return await self.client.like_playlist(prov_item_id)
674 return False
675
676 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
677 """
678 Remove item from library.
679
680 :param prov_item_id: The provider item ID.
681 :param media_type: The media type.
682 :return: True if successful.
683 """
684 if media_type == MediaType.TRACK:
685 return await self.client.unlike_track(prov_item_id)
686 if media_type == MediaType.ALBUM:
687 return await self.client.unlike_release(prov_item_id)
688 if media_type == MediaType.ARTIST:
689 return await self.client.unlike_artist(prov_item_id)
690 if media_type == MediaType.PLAYLIST:
691 return await self.client.unlike_playlist(prov_item_id)
692 return False
693
694 def _get_provider_item_id(self, item: MediaItemType) -> str | None:
695 """Get provider item ID from media item."""
696 for mapping in item.provider_mappings:
697 if mapping.provider_instance == self.instance_id:
698 return mapping.item_id
699 return item.item_id if item.provider == self.instance_id else None
700
701 # Playlist management
702
703 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
704 """
705 Create a new playlist.
706
707 :param name: Playlist name.
708 :param media_types: Ignored â Zvuk playlists are always track-based.
709 :return: The created Playlist object.
710 """
711 playlist_id = await self.client.create_playlist(name)
712 playlist = await self.client.get_playlist(playlist_id)
713 if not playlist:
714 raise MediaNotFoundError(f"Created playlist {playlist_id} not found")
715 return parse_playlist(self, playlist)
716
717 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
718 """
719 Add tracks to a playlist.
720
721 :param prov_playlist_id: The provider playlist ID.
722 :param prov_track_ids: List of track IDs to add.
723 """
724 await self.client.add_tracks_to_playlist(prov_playlist_id, prov_track_ids)
725
726 async def remove_playlist_tracks(
727 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
728 ) -> None:
729 """
730 Remove tracks from a playlist by position.
731
732 :param prov_playlist_id: The provider playlist ID.
733 :param positions_to_remove: Tuple of track positions (0-based) to remove.
734 """
735 # Fetch current tracks and filter out the ones at given positions
736 simple_tracks = await self.client.get_playlist_tracks(
737 prov_playlist_id, limit=PLAYLIST_TRACK_FETCH_LIMIT
738 )
739 remove_positions = set(positions_to_remove)
740 remaining_ids = [
741 str(t.id) for i, t in enumerate(simple_tracks) if t.id and i not in remove_positions
742 ]
743 await self.client.update_playlist(prov_playlist_id, remaining_ids)
744
745 # Streaming
746
747 async def get_stream_details(
748 self, item_id: str, media_type: MediaType = MediaType.TRACK
749 ) -> StreamDetails:
750 """
751 Get stream details for a track.
752
753 Uses /api/tiny/track/stream to get a direct (non-DRM) URL. When lossless is
754 requested, always tries "flac" quality first, then falls back through "high"
755 (320kbps MP3) â "mid" (128kbps MP3). The ``has_flac`` field from the API is
756 not reliable enough to skip the FLAC attempt.
757
758 :param item_id: The track ID.
759 :param media_type: The media type (should be TRACK).
760 :return: StreamDetails for the track.
761 :raises MediaNotFoundError: If stream URL cannot be obtained.
762 """
763 quality_pref = self.config.get_value(CONF_QUALITY)
764 quality_str = str(quality_pref) if quality_pref is not None else QUALITY_LOSSLESS
765
766 # Fetch track metadata for duration.
767 # has_flac is read for diagnostics only â it is not reliable enough to
768 # skip the FLAC attempt, so it does not affect the quality fallback chain.
769 track = await self.client.get_track(item_id)
770 duration: int | None = None
771 has_flac: bool | None = None
772 if track is not None:
773 if getattr(track, "duration", None) is not None:
774 duration = int(track.duration)
775 if getattr(track, "has_flac", None) is not None:
776 has_flac = bool(track.has_flac)
777
778 # Build quality fallback chain using /api/tiny/track/stream (plain, non-DRM URLs).
779 # This endpoint returns {"result": {"stream": "https://..."}} for FLAC/MP3 qualities.
780 # zvuk-dl-rs uses the same endpoint to download lossless FLAC.
781 self.logger.debug(
782 "Stream request for track %s: quality_pref=%s has_flac=%s (diagnostic only)",
783 item_id,
784 quality_str,
785 has_flac,
786 )
787 if quality_str == QUALITY_LOSSLESS:
788 quality_chain = [
789 ("flac", ContentType.FLAC, 0),
790 ("high", ContentType.MP3, 320),
791 ("mid", ContentType.MP3, 128),
792 ]
793 else:
794 quality_chain = [("high", ContentType.MP3, 320), ("mid", ContentType.MP3, 128)]
795
796 url: str | None = None
797 content_type = ContentType.UNKNOWN
798 bitrate = 0
799
800 for q_str, q_content_type, q_bitrate in quality_chain:
801 try:
802 url = await self.client.get_direct_stream_url(item_id, q_str)
803 except (ResourceTemporarilyUnavailable, ProviderUnavailableError) as err:
804 self.logger.warning(
805 "Error getting stream URL for track %s quality=%s: %s",
806 item_id,
807 q_str,
808 err,
809 )
810 continue
811 self.logger.debug(
812 "Stream URL for track %s quality=%s: %s",
813 item_id,
814 q_str,
815 "OK" if url else "None",
816 )
817 if url:
818 content_type = q_content_type
819 bitrate = q_bitrate
820 break
821
822 if not url:
823 raise MediaNotFoundError(f"No stream URL available for track {item_id}")
824
825 return StreamDetails(
826 item_id=item_id,
827 provider=self.instance_id,
828 audio_format=AudioFormat(
829 content_type=content_type,
830 bit_rate=bitrate,
831 ),
832 stream_type=StreamType.HTTP,
833 path=url,
834 duration=duration,
835 allow_seek=True,
836 can_seek=True,
837 )
838