/
/
/
1"""Yandex Music provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7import random
8from collections.abc import AsyncGenerator, Sequence
9from datetime import UTC, datetime
10from io import BytesIO
11from typing import TYPE_CHECKING, Any
12
13from music_assistant_models.enums import ImageType, MediaType, ProviderFeature
14from music_assistant_models.errors import (
15 InvalidDataError,
16 LoginFailed,
17 MediaNotFoundError,
18 ProviderUnavailableError,
19 ResourceTemporarilyUnavailable,
20)
21from music_assistant_models.media_items import (
22 Album,
23 Artist,
24 BrowseFolder,
25 ItemMapping,
26 MediaItemImage,
27 MediaItemType,
28 Playlist,
29 ProviderMapping,
30 RecommendationFolder,
31 SearchResults,
32 Track,
33 UniqueList,
34)
35from PIL import Image as PilImage
36
37from music_assistant.controllers.cache import use_cache
38from music_assistant.models.music_provider import MusicProvider
39
40from .api_client import YandexMusicClient
41from .constants import (
42 BROWSE_INITIAL_TRACKS,
43 BROWSE_NAMES_EN,
44 BROWSE_NAMES_RU,
45 COLLECTION_FOLDER_ID,
46 CONF_BASE_URL,
47 CONF_LIKED_TRACKS_MAX_TRACKS,
48 CONF_MY_WAVE_MAX_TRACKS,
49 CONF_TOKEN,
50 DEFAULT_BASE_URL,
51 DISCOVERY_INITIAL_TRACKS,
52 FOR_YOU_FOLDER_ID,
53 IMAGE_SIZE_MEDIUM,
54 LIKED_TRACKS_PLAYLIST_ID,
55 MY_WAVE_BATCH_SIZE,
56 MY_WAVE_PLAYLIST_ID,
57 MY_WAVES_FOLDER_ID,
58 MY_WAVES_SET_FOLDER_ID,
59 PLAYLIST_ID_SPLITTER,
60 RADIO_FOLDER_ID,
61 RADIO_TRACK_ID_SEP,
62 ROTOR_STATION_MY_WAVE,
63 TAG_CATEGORY_ACTIVITY,
64 TAG_CATEGORY_ERA,
65 TAG_CATEGORY_GENRES,
66 TAG_CATEGORY_MOOD,
67 TAG_CATEGORY_ORDER,
68 TAG_MIXES,
69 TAG_SEASONAL_MAP,
70 TAG_SLUG_CATEGORY,
71 TRACK_BATCH_SIZE,
72 WAVE_CATEGORY_DISPLAY_ORDER,
73 WAVES_FOLDER_ID,
74 WAVES_LANDING_FOLDER_ID,
75)
76from .parsers import (
77 _get_image_url as get_image_url,
78)
79from .parsers import (
80 get_canonical_provider_name,
81 parse_album,
82 parse_artist,
83 parse_playlist,
84 parse_track,
85)
86from .streaming import YandexMusicStreamingManager
87
88if TYPE_CHECKING:
89 from music_assistant_models.streamdetails import StreamDetails
90
91
92def _parse_radio_item_id(item_id: str) -> tuple[str, str | None]:
93 """Extract track_id and optional station_id from provider item_id.
94
95 My Wave tracks use item_id format 'track_id@station_id'. Other tracks use
96 plain track_id.
97
98 :param item_id: Provider item_id (may contain RADIO_TRACK_ID_SEP).
99 :return: (track_id, station_id or None).
100 """
101 if RADIO_TRACK_ID_SEP in item_id:
102 parts = item_id.split(RADIO_TRACK_ID_SEP, 1)
103 return (parts[0], parts[1] if len(parts) > 1 else None)
104 return (item_id, None)
105
106
107class _WaveState:
108 """Per-station mutable state for rotor wave playback."""
109
110 def __init__(self) -> None:
111 self.batch_id: str | None = None
112 self.last_track_id: str | None = None
113 self.seen_track_ids: set[str] = set()
114 self.radio_started_sent: bool = False
115 self.lock: asyncio.Lock = asyncio.Lock()
116
117
118class YandexMusicProvider(MusicProvider):
119 """Implementation of a Yandex Music MusicProvider."""
120
121 _client: YandexMusicClient | None = None
122 _streaming: YandexMusicStreamingManager | None = None
123 _my_wave_batch_id: str | None = None
124 _my_wave_last_track_id: str | None = None # last track id for "Load more" (API queue param)
125 _my_wave_playlist_next_cursor: str | None = None # first_track_id for next playlist page
126 _my_wave_radio_started_sent: bool = False
127 _my_wave_seen_track_ids: set[str] # Track IDs seen in current My Wave session
128 _my_wave_lock: asyncio.Lock # Protects My Wave mutable state
129 _wave_states: dict[str, _WaveState] # Per-station state for tagged wave stations
130 _wave_bg_colors: dict[str, str] # image_url -> hex bg color for transparent covers
131
132 @property
133 def client(self) -> YandexMusicClient:
134 """Return the Yandex Music client."""
135 if self._client is None:
136 raise ProviderUnavailableError("Provider not initialized")
137 return self._client
138
139 @property
140 def streaming(self) -> YandexMusicStreamingManager:
141 """Return the streaming manager."""
142 if self._streaming is None:
143 raise ProviderUnavailableError("Provider not initialized")
144 return self._streaming
145
146 def _get_browse_names(self) -> dict[str, str]:
147 """Get locale-based browse folder names."""
148 try:
149 locale = (self.mass.metadata.locale or "en_US").lower()
150 use_russian = locale.startswith("ru")
151 self.logger.debug("Locale detection: locale=%s, use_russian=%s", locale, use_russian)
152 except Exception as err:
153 self.logger.debug("Locale detection failed: %s", err)
154 use_russian = False
155 return BROWSE_NAMES_RU if use_russian else BROWSE_NAMES_EN
156
157 async def handle_async_init(self) -> None:
158 """Handle async initialization of the provider."""
159 token = self.config.get_value(CONF_TOKEN)
160 if not token:
161 raise LoginFailed("No Yandex Music token provided")
162
163 base_url = self.config.get_value(CONF_BASE_URL, DEFAULT_BASE_URL)
164 self._client = YandexMusicClient(str(token), base_url=str(base_url))
165 await self._client.connect()
166 # Suppress yandex_music library DEBUG dumps (full API request/response JSON)
167 logging.getLogger("yandex_music").setLevel(self.logger.level + 10)
168 self._streaming = YandexMusicStreamingManager(self)
169 # Initialize My Wave duplicate tracking
170 self._my_wave_seen_track_ids = set()
171 self._my_wave_lock = asyncio.Lock()
172 # Initialize per-station wave state dict
173 self._wave_states = {}
174 self._wave_bg_colors = {}
175 self.logger.info("Successfully connected to Yandex Music")
176
177 async def unload(self, is_removed: bool = False) -> None:
178 """Handle unload/close of the provider.
179
180 :param is_removed: Whether the provider is being removed.
181 """
182 if self._client:
183 await self._client.disconnect()
184 self._client = None
185 self._streaming = None
186 await super().unload(is_removed)
187
188 def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping:
189 """Create a generic item mapping.
190
191 :param media_type: The media type.
192 :param key: The item ID.
193 :param name: The item name.
194 :return: An ItemMapping instance.
195 """
196 if isinstance(media_type, str):
197 media_type = MediaType(media_type)
198 return ItemMapping(
199 media_type=media_type,
200 item_id=key,
201 provider=self.instance_id,
202 name=name,
203 )
204
205 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
206 """Browse provider items with locale-based folder names and My Wave.
207
208 Root level shows My Wave, artists, albums, liked tracks, playlists. Names
209 are in Russian when MA locale is ru_*, otherwise in English. My Wave
210 tracks use item_id format track_id@station_id for rotor feedback.
211
212 :param path: The path to browse (e.g. provider_id:// or provider_id://artists).
213 """
214 if ProviderFeature.BROWSE not in self.supported_features:
215 raise NotImplementedError
216
217 path_parts = path.split("://")[1].split("/") if "://" in path else []
218 subpath = path_parts[0] if len(path_parts) > 0 else None
219 sub_subpath = path_parts[1] if len(path_parts) > 1 else None
220
221 if subpath == MY_WAVE_PLAYLIST_ID:
222 async with self._my_wave_lock:
223 return await self._browse_my_wave(path, sub_subpath)
224
225 # For You folder (picks + mixes)
226 if subpath == FOR_YOU_FOLDER_ID:
227 return await self._browse_for_you(path, path_parts)
228
229 # Collection folder (library items)
230 if subpath == COLLECTION_FOLDER_ID:
231 return await self._browse_collection(path)
232
233 # Handle picks/ path (mood, activity, era, genres)
234 if subpath == "picks":
235 return await self._browse_picks(path, path_parts)
236
237 # Handle mixes/ path (seasonal collections)
238 if subpath == "mixes":
239 return await self._browse_mixes(path, path_parts)
240
241 # Handle waves/ and radio/ paths (rotor stations by genre/mood/activity)
242 if subpath in (WAVES_FOLDER_ID, RADIO_FOLDER_ID):
243 return await self._browse_waves(path, path_parts)
244
245 # Handle my_waves_set/ path (AI Wave Sets from /landing-blocks/mixes-waves)
246 if subpath == MY_WAVES_SET_FOLDER_ID:
247 return await self._browse_vibe_sets(path, path_parts)
248
249 # Handle waves_landing/ path (Featured Waves from /landing-blocks/waves)
250 if subpath == WAVES_LANDING_FOLDER_ID:
251 return await self._browse_waves_landing(path, path_parts)
252
253 # Handle direct tag subpath (when folder is played by URI, the full path
254 # "picks/category/tag" is lost and only the tag slug arrives as subpath).
255 # Skip the API call for standard top-level folders that are never tag slugs.
256 _known_folders = {
257 "artists",
258 "albums",
259 "tracks",
260 "playlists",
261 LIKED_TRACKS_PLAYLIST_ID,
262 WAVES_FOLDER_ID,
263 RADIO_FOLDER_ID,
264 MY_WAVES_FOLDER_ID,
265 MY_WAVES_SET_FOLDER_ID,
266 WAVES_LANDING_FOLDER_ID,
267 FOR_YOU_FOLDER_ID,
268 COLLECTION_FOLDER_ID,
269 }
270 if subpath and subpath not in _known_folders:
271 # Handle direct wave station_id (e.g. "activity:workout") passed when
272 # MA plays a wave station folder using its item_id as the path subpath.
273 # Station IDs have format "category:tag" where category is non-numeric.
274 if ":" in subpath:
275 cat_part = subpath.split(":", 1)[0]
276 if not cat_part.isdigit():
277 return await self._browse_wave_station(subpath)
278
279 discovered_tags = await self._get_discovered_tag_slugs()
280 if subpath in discovered_tags:
281 return await self._get_tag_playlists_as_browse(subpath)
282
283 if subpath:
284 return await super().browse(path)
285
286 names = self._get_browse_names()
287
288 folders: list[BrowseFolder] = []
289 base = path if path.endswith("//") else path.rstrip("/") + "/"
290 # My Wave folder (always enabled â Ð¯Ð½Ð´ÐµÐºÑ Â«ÐÐ¾Ñ Ð²Ð¾Ð»Ð½Ð°Â»)
291 folders.append(
292 BrowseFolder(
293 item_id=MY_WAVE_PLAYLIST_ID,
294 provider=self.instance_id,
295 path=f"{base}{MY_WAVE_PLAYLIST_ID}",
296 name=names[MY_WAVE_PLAYLIST_ID],
297 is_playable=True,
298 )
299 )
300 # For You folder â Picks + Mixes (Ð¯Ð½Ð´ÐµÐºÑ Â«ÐÐ»Ñ Ð²Ð°Ñ»)
301 folders.append(
302 BrowseFolder(
303 item_id=FOR_YOU_FOLDER_ID,
304 provider=self.instance_id,
305 path=f"{base}{FOR_YOU_FOLDER_ID}",
306 name=names.get(FOR_YOU_FOLDER_ID, "For You"),
307 is_playable=False,
308 )
309 )
310 # Collection folder â library items (Ð¯Ð½Ð´ÐµÐºÑ Â«ÐоллекÑиÑ»)
311 has_library = any(
312 f in self.supported_features
313 for f in (
314 ProviderFeature.LIBRARY_ARTISTS,
315 ProviderFeature.LIBRARY_ALBUMS,
316 ProviderFeature.LIBRARY_TRACKS,
317 ProviderFeature.LIBRARY_PLAYLISTS,
318 )
319 )
320 if has_library:
321 folders.append(
322 BrowseFolder(
323 item_id=COLLECTION_FOLDER_ID,
324 provider=self.instance_id,
325 path=f"{base}{COLLECTION_FOLDER_ID}",
326 name=names.get(COLLECTION_FOLDER_ID, "Collection"),
327 is_playable=False,
328 )
329 )
330 # Radio folder â rotor stations (Ð¯Ð½Ð´ÐµÐºÑ Ð²Ð¾Ð»Ð½Ñ, renamed to Radio)
331 folders.append(
332 BrowseFolder(
333 item_id=RADIO_FOLDER_ID,
334 provider=self.instance_id,
335 path=f"{base}{RADIO_FOLDER_ID}",
336 name=names.get(RADIO_FOLDER_ID, "Radio"),
337 is_playable=False,
338 )
339 )
340 # AI Wave Sets â parametric stations from /landing-blocks/mixes-waves
341 folders.append(
342 BrowseFolder(
343 item_id=MY_WAVES_SET_FOLDER_ID,
344 provider=self.instance_id,
345 path=f"{base}{MY_WAVES_SET_FOLDER_ID}",
346 name=names.get(MY_WAVES_SET_FOLDER_ID, "AI Wave Sets"),
347 is_playable=False,
348 )
349 )
350 if len(folders) == 1:
351 return await self.browse(folders[0].path)
352 return folders
353
354 async def _browse_my_wave(
355 self, path: str, sub_subpath: str | None
356 ) -> list[Track | BrowseFolder]:
357 """Browse My Wave tracks (must be called under _my_wave_lock).
358
359 :param path: Full browse path.
360 :param sub_subpath: Sub-path part ('next' for load more, or track_id cursor).
361 :return: List of Track and optional BrowseFolder for "Load more".
362 """
363 max_tracks_config = int(
364 self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type]
365 )
366 batch_size_config = MY_WAVE_BATCH_SIZE
367
368 # Effective limit on tracks to collect for this call:
369 # initial browse is capped to BROWSE_INITIAL_TRACKS to avoid marking
370 # extra tracks as "seen" that are never shown to the user.
371 effective_limit = min(
372 BROWSE_INITIAL_TRACKS if sub_subpath != "next" else max_tracks_config,
373 max_tracks_config,
374 )
375
376 # Root my_wave: fetch up to batch_size_config batches so Play adds more tracks.
377 # "Load more" always uses single next batch.
378 max_batches = batch_size_config if sub_subpath != "next" else 1
379
380 # Reset seen tracks on fresh browse (not "load more")
381 if sub_subpath != "next":
382 self._my_wave_seen_track_ids = set()
383
384 queue: str | int | None = None
385 if sub_subpath == "next":
386 queue = self._my_wave_last_track_id
387 elif sub_subpath:
388 queue = sub_subpath
389
390 all_tracks: list[Track | BrowseFolder] = []
391 last_batch_id: str | None = None
392 first_track_id_this_batch: str | None = None
393 total_track_count = 0
394
395 for _ in range(max_batches):
396 if total_track_count >= effective_limit:
397 break
398
399 yandex_tracks, batch_id = await self.client.get_my_wave_tracks(queue=queue)
400 if batch_id:
401 self._my_wave_batch_id = batch_id
402 last_batch_id = batch_id
403 if not self._my_wave_radio_started_sent and yandex_tracks:
404 sent = await self.client.send_rotor_station_feedback(
405 ROTOR_STATION_MY_WAVE,
406 "radioStarted",
407 batch_id=batch_id,
408 )
409 if sent:
410 self._my_wave_radio_started_sent = True
411 first_track_id_this_batch = None
412 for yt in yandex_tracks:
413 if total_track_count >= effective_limit:
414 break
415
416 track = self._parse_my_wave_track(yt, self._my_wave_seen_track_ids)
417 if track is None:
418 continue
419 all_tracks.append(track)
420 total_track_count += 1
421
422 track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0]
423 if first_track_id_this_batch is None:
424 first_track_id_this_batch = track_id
425
426 if first_track_id_this_batch is not None:
427 self._my_wave_last_track_id = first_track_id_this_batch
428 if (
429 first_track_id_this_batch is None
430 or not batch_id
431 or not yandex_tracks
432 or total_track_count >= effective_limit
433 ):
434 break
435 queue = first_track_id_this_batch
436
437 # Only show "Load more" if we haven't reached the limit and there's more data
438 if last_batch_id and total_track_count < max_tracks_config:
439 names = self._get_browse_names()
440 next_name = "ÐÑÑ" if names == BROWSE_NAMES_RU else "Load more"
441 all_tracks.append(
442 BrowseFolder(
443 item_id="next",
444 provider=self.instance_id,
445 path=f"{path.rstrip('/')}/next",
446 name=next_name,
447 is_playable=False,
448 )
449 )
450 return all_tracks
451
452 def _parse_my_wave_track(self, yt: Any, seen_ids: set[str]) -> Track | None:
453 """Parse a Yandex track into a My Wave Track with composite item_id.
454
455 Extracts the track_id, checks for duplicates in the seen_ids set,
456 sets composite item_id (track_id@station_id), and updates provider_mappings.
457 Callers using shared state must hold _my_wave_lock.
458
459 :param yt: Yandex track object from rotor station response.
460 :param seen_ids: Set of already-seen track IDs to check and update.
461 :return: Parsed Track with composite item_id, or None if duplicate/invalid.
462 """
463 try:
464 t = parse_track(self, yt)
465 except InvalidDataError as err:
466 self.logger.debug("Error parsing My Wave track: %s", err)
467 return None
468
469 track_id = str(yt.id) if hasattr(yt, "id") and yt.id else getattr(yt, "track_id", None)
470 if not track_id:
471 return t
472
473 if track_id in seen_ids:
474 self.logger.debug("Skipping duplicate My Wave track: %s", track_id)
475 return None
476
477 seen_ids.add(track_id)
478 t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
479 for pm in t.provider_mappings:
480 if pm.provider_instance == self.instance_id:
481 pm.item_id = t.item_id
482 break
483 return t
484
485 @use_cache(3600)
486 async def _validate_tag(self, tag_slug: str) -> bool:
487 """Check if a tag has playlists by calling client.get_tag_playlists().
488
489 :param tag_slug: Tag identifier (e.g. 'chill', '80s').
490 :return: True if the tag has at least one playlist.
491 """
492 try:
493 playlists = await self.client.get_tag_playlists(tag_slug)
494 return len(playlists) > 0
495 except Exception as err:
496 self.logger.debug("Tag validation failed for %s: %s", tag_slug, err)
497 return False
498
499 @use_cache(3600)
500 async def _get_valid_tags_for_category(self, category: str) -> list[str]:
501 """Get validated tags for a category (only those with playlists).
502
503 Combines hardcoded tags from the category lists with any landing-discovered
504 tags, validates each by calling client.tags(), and returns only those with
505 playlists.
506
507 :param category: Category name ('mood', 'activity', 'era', 'genres').
508 :return: List of valid tag slugs.
509 """
510 category_lists: dict[str, list[str]] = {
511 "mood": list(TAG_CATEGORY_MOOD),
512 "activity": list(TAG_CATEGORY_ACTIVITY),
513 "era": list(TAG_CATEGORY_ERA),
514 "genres": list(TAG_CATEGORY_GENRES),
515 }
516 tags = category_lists.get(category, [])
517
518 # Add landing-discovered tags for this category
519 try:
520 landing_tags = await self.client.get_landing_tags()
521 for slug, _title in landing_tags:
522 cat = TAG_SLUG_CATEGORY.get(slug, "mood")
523 if cat == category and slug not in tags:
524 tags.append(slug)
525 except Exception as err:
526 self.logger.debug("Landing tag discovery failed: %s", err)
527
528 # Validate tags in parallel with bounded concurrency
529 sem = asyncio.Semaphore(8)
530
531 async def _check(tag: str) -> str | None:
532 async with sem:
533 return tag if await self._validate_tag(tag) else None
534
535 results = await asyncio.gather(*[_check(tag) for tag in tags])
536 return [tag for tag in results if tag is not None]
537
538 @use_cache(3600)
539 async def _get_discovered_tags(self, locale: str) -> list[tuple[str, str]]:
540 """Get all available tags by combining hardcoded tags with landing discovery.
541
542 Starts with all hardcoded tags from category lists, adds landing-discovered
543 tags, validates each via client.tags(), and returns only those with playlists.
544 Results are cached for 1 hour. The locale parameter is included in the cache
545 key so that a locale change invalidates the cached result.
546
547 :param locale: Current metadata locale (used as part of cache key).
548 :return: List of (slug, title) tuples for tags that have playlists.
549 """
550 names = self._get_browse_names()
551
552 # Collect all hardcoded tags (non-seasonal)
553 all_tags: dict[str, str] = {}
554 for slug, cat in TAG_SLUG_CATEGORY.items():
555 if cat != "seasonal":
556 all_tags[slug] = names.get(slug, slug.title())
557
558 # Add landing-discovered tags
559 try:
560 landing_tags = await self.client.get_landing_tags()
561 for slug, title in landing_tags:
562 if slug not in all_tags:
563 all_tags[slug] = title
564 except Exception as err:
565 self.logger.debug("Failed to discover tags from landing API: %s", err)
566
567 # Validate tags in parallel with bounded concurrency
568 sem = asyncio.Semaphore(8)
569
570 async def _check(slug: str) -> bool:
571 async with sem:
572 return await self._validate_tag(slug)
573
574 tag_items = list(all_tags.items())
575 results = await asyncio.gather(*[_check(slug) for slug, _ in tag_items])
576 return [
577 (slug, title) for (slug, title), valid in zip(tag_items, results, strict=True) if valid
578 ]
579
580 async def _get_discovered_tag_slugs(self) -> set[str]:
581 """Get set of all valid tag slugs (cached).
582
583 :return: Set of tag slug strings that have playlists.
584 """
585 discovered = await self._get_discovered_tags(self.mass.metadata.locale or "en_US")
586 return {slug for slug, _title in discovered}
587
588 async def _browse_for_you(
589 self, path: str, path_parts: list[str]
590 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
591 """Browse «For You» folder â shows Picks and Mixes sub-folders.
592
593 :param path: Full browse path.
594 :param path_parts: Split path parts after ://.
595 :return: List of sub-folders (Picks, Mixes).
596 """
597 names = self._get_browse_names()
598 # Strip the for_you segment to build child paths that route to picks/mixes
599 # Path format: ...//for_you â child paths should be ...//picks, ...//mixes
600 # We build base from the root (before for_you) by dropping the last segment.
601 base_parts = path.split("//", 1)
602 root_base = (base_parts[0] + "//") if len(base_parts) > 1 else path.rstrip("/") + "/"
603
604 if len(path_parts) == 1:
605 return [
606 BrowseFolder(
607 item_id="picks",
608 provider=self.instance_id,
609 path=f"{root_base}picks",
610 name=names.get("picks", "Picks"),
611 is_playable=False,
612 ),
613 BrowseFolder(
614 item_id="mixes",
615 provider=self.instance_id,
616 path=f"{root_base}mixes",
617 name=names.get("mixes", "Mixes"),
618 is_playable=False,
619 ),
620 ]
621 # Deeper path: delegate to picks or mixes handler via canonical paths
622 return await super().browse(path)
623
624 async def _browse_collection(
625 self, path: str
626 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
627 """Browse «Collection» folder â shows library sub-folders (tracks/artists/albums/playlists).
628
629 :param path: Full browse path.
630 :return: List of library sub-folders.
631 """
632 names = self._get_browse_names()
633 base_parts = path.split("//", 1)
634 root_base = (base_parts[0] + "//") if len(base_parts) > 1 else path.rstrip("/") + "/"
635
636 folders: list[BrowseFolder] = []
637 if ProviderFeature.LIBRARY_TRACKS in self.supported_features:
638 folders.append(
639 BrowseFolder(
640 item_id="tracks",
641 provider=self.instance_id,
642 path=f"{root_base}tracks",
643 name=names["tracks"],
644 is_playable=True,
645 )
646 )
647 if ProviderFeature.LIBRARY_ARTISTS in self.supported_features:
648 folders.append(
649 BrowseFolder(
650 item_id="artists",
651 provider=self.instance_id,
652 path=f"{root_base}artists",
653 name=names["artists"],
654 is_playable=True,
655 )
656 )
657 if ProviderFeature.LIBRARY_ALBUMS in self.supported_features:
658 folders.append(
659 BrowseFolder(
660 item_id="albums",
661 provider=self.instance_id,
662 path=f"{root_base}albums",
663 name=names["albums"],
664 is_playable=True,
665 )
666 )
667 if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
668 folders.append(
669 BrowseFolder(
670 item_id="playlists",
671 provider=self.instance_id,
672 path=f"{root_base}playlists",
673 name=names["playlists"],
674 is_playable=True,
675 )
676 )
677 return folders
678
679 async def _browse_picks(
680 self, path: str, path_parts: list[str]
681 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
682 """Browse picks folder using hardcoded tags validated against the API.
683
684 Tags are sourced from hardcoded category lists and landing API discovery,
685 then validated via client.tags() to ensure they have playlists.
686 Only categories with at least one valid tag are shown.
687
688 :param path: Full browse path.
689 :param path_parts: Split path parts after ://.
690 :return: List of folders or playlists.
691 """
692 names = self._get_browse_names()
693 base = path.rstrip("/") + "/"
694
695 # Get validated tags
696 discovered = await self._get_discovered_tags(self.mass.metadata.locale or "en_US")
697
698 # Categorize valid tags
699 categorized: dict[str, list[tuple[str, str]]] = {}
700 for slug, title in discovered:
701 cat = TAG_SLUG_CATEGORY.get(slug, "mood")
702 # Skip seasonal tags â they belong in mixes, not picks
703 if cat == "seasonal":
704 continue
705 categorized.setdefault(cat, []).append((slug, title))
706
707 # Sort tags within each category by preferred order
708 for cat, cat_tags in categorized.items():
709 order = TAG_CATEGORY_ORDER.get(cat, [])
710 order_map = {s: i for i, s in enumerate(order)}
711 cat_tags.sort(key=lambda t: order_map.get(t[0], len(order)))
712
713 # picks/ - show category folders (only those with valid tags)
714 if len(path_parts) == 1:
715 category_display_order = ["mood", "activity", "era", "genres"]
716 folders: list[BrowseFolder] = []
717 for cat in category_display_order:
718 if cat in categorized:
719 folders.append(
720 BrowseFolder(
721 item_id=cat,
722 provider=self.instance_id,
723 path=f"{base}{cat}",
724 name=names.get(cat, cat.title()),
725 is_playable=False,
726 )
727 )
728 # Show any extra categories not in the standard order
729 for cat in categorized:
730 if cat not in category_display_order:
731 folders.append(
732 BrowseFolder(
733 item_id=cat,
734 provider=self.instance_id,
735 path=f"{base}{cat}",
736 name=names.get(cat, cat.title()),
737 is_playable=False,
738 )
739 )
740 return folders
741
742 category: str | None = path_parts[1] if len(path_parts) > 1 else None
743 tag: str | None = path_parts[2] if len(path_parts) > 2 else None
744
745 self.logger.debug(
746 "Browse picks: path=%s, category=%s, tag=%s",
747 path,
748 category,
749 tag,
750 )
751
752 # picks/category/ - show valid tag folders for this category
753 if category and not tag:
754 category_tags = categorized.get(category, [])
755 folders = []
756 for slug, title in category_tags:
757 folders.append(
758 BrowseFolder(
759 item_id=slug,
760 provider=self.instance_id,
761 path=f"{base}{slug}",
762 name=names.get(slug, title),
763 is_playable=False,
764 )
765 )
766 self.logger.debug("Returning %d tag folders for category %s", len(folders), category)
767 return folders
768
769 # picks/category/tag - show playlists for the tag
770 if tag:
771 discovered_slugs = {slug for slug, _ in discovered}
772 if tag in discovered_slugs:
773 self.logger.debug("Fetching playlists for tag: %s", tag)
774 return await self._get_tag_playlists_as_browse(tag)
775
776 self.logger.debug("No match found, returning empty list")
777 return []
778
779 async def _browse_mixes(
780 self, path: str, path_parts: list[str]
781 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
782 """Browse mixes folder (seasonal collections) using hardcoded tags.
783
784 Uses TAG_MIXES directly and validates each tag via client.tags()
785 to check if it has playlists. Does not depend on landing API discovery.
786
787 :param path: Full browse path.
788 :param path_parts: Split path parts after ://.
789 :return: List of folders or playlists.
790 """
791 names = self._get_browse_names()
792 base = path.rstrip("/") + "/"
793
794 # Validate seasonal tags in parallel (no landing dependency)
795 sem = asyncio.Semaphore(5)
796
797 async def _check(tag: str) -> str | None:
798 async with sem:
799 return tag if await self._validate_tag(tag) else None
800
801 results = await asyncio.gather(*[_check(t) for t in TAG_MIXES])
802 available_mixes = [t for t in results if t is not None]
803
804 # mixes/ - show seasonal folders (only valid ones)
805 if len(path_parts) == 1:
806 folders = []
807 for t in available_mixes:
808 folders.append(
809 BrowseFolder(
810 item_id=t,
811 provider=self.instance_id,
812 path=f"{base}{t}",
813 name=names.get(t, t.title()),
814 is_playable=False,
815 )
816 )
817 return folders
818
819 # mixes/tag - show playlists for the tag
820 tag = path_parts[1] if len(path_parts) > 1 else None
821 if tag and tag in TAG_MIXES:
822 return await self._get_tag_playlists_as_browse(tag)
823
824 return []
825
826 def _get_wave_state(self, station_id: str) -> _WaveState:
827 """Get or create per-station wave state.
828
829 :param station_id: Rotor station ID (e.g. 'genre:rock', 'mood:chill').
830 :return: _WaveState instance for this station.
831 """
832 return self._wave_states.setdefault(station_id, _WaveState())
833
834 async def _browse_waves(
835 self, path: str, path_parts: list[str]
836 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
837 """Browse waves folder (rotor stations by genre/mood/activity/epoch/local).
838
839 Fetches available stations from the Yandex rotor API and groups them by category.
840
841 :param path: Full browse path.
842 :param path_parts: Split path parts after ://.
843 :return: List of folders or tracks.
844 """
845 names = self._get_browse_names()
846 base = path.rstrip("/") + "/"
847
848 locale = (self.mass.metadata.locale or "en_US").lower()
849 language = "ru" if locale.startswith("ru") else "en"
850
851 all_stations = await self.client.get_wave_stations(language)
852
853 # Group stations by category, preserving image_url
854 categorized: dict[str, list[tuple[str, str, str | None]]] = {}
855 for station_id, cat_key, name, image_url in all_stations:
856 categorized.setdefault(cat_key, []).append((station_id, name, image_url))
857
858 # waves/ â show category folders
859 if len(path_parts) == 1:
860 folders: list[BrowseFolder] = []
861 # Personalized "My Waves" first â only show if dashboard returns stations
862 dashboard_stations = await self._get_dashboard_stations_cached()
863 if dashboard_stations:
864 folders.append(
865 BrowseFolder(
866 item_id=MY_WAVES_FOLDER_ID,
867 provider=self.instance_id,
868 path=f"{base}{MY_WAVES_FOLDER_ID}",
869 name=names.get(MY_WAVES_FOLDER_ID, "My Waves"),
870 is_playable=False,
871 )
872 )
873 # Featured Waves â only show if landing-blocks/waves returns data
874 waves_landing = await self._get_waves_landing_cached()
875 if waves_landing:
876 folders.append(
877 BrowseFolder(
878 item_id=WAVES_LANDING_FOLDER_ID,
879 provider=self.instance_id,
880 path=f"{base}{WAVES_LANDING_FOLDER_ID}",
881 name=names.get(WAVES_LANDING_FOLDER_ID, "Featured Waves"),
882 is_playable=False,
883 )
884 )
885 for cat in WAVE_CATEGORY_DISPLAY_ORDER:
886 if cat in categorized:
887 folders.append(
888 BrowseFolder(
889 item_id=cat,
890 provider=self.instance_id,
891 path=f"{base}{cat}",
892 name=names.get(cat, cat.title()),
893 is_playable=False,
894 )
895 )
896 # Append any categories returned by API that aren't in the predefined order
897 for cat in categorized:
898 if cat not in WAVE_CATEGORY_DISPLAY_ORDER:
899 folders.append(
900 BrowseFolder(
901 item_id=cat,
902 provider=self.instance_id,
903 path=f"{base}{cat}",
904 name=names.get(cat, cat.title()),
905 is_playable=False,
906 )
907 )
908 return folders
909
910 category: str | None = path_parts[1] if len(path_parts) > 1 else None
911 tag: str | None = path_parts[2] if len(path_parts) > 2 else None
912
913 # waves/my_waves/ â show personalized stations from dashboard
914 if category == MY_WAVES_FOLDER_ID and not tag:
915 return await self._browse_my_waves_stations(path)
916
917 # waves/waves_landing/... â redirect to Featured Waves browse
918 if category == WAVES_LANDING_FOLDER_ID:
919 return await self._browse_waves_landing(path, path_parts[1:])
920
921 # waves/my_waves/<tag>[/next] â play a specific personal station
922 # The full station_id has format "genre:allrock", not "my_waves:allrock".
923 # Resolve by matching against dashboard stations cache.
924 if category == MY_WAVES_FOLDER_ID and tag:
925 dashboard_stations = await self._get_dashboard_stations_cached()
926 for sid, _, _ in dashboard_stations:
927 sid_tag = sid.split(":", 1)[1] if ":" in sid else sid
928 if sid_tag == tag:
929 return await self._browse_wave_station(sid, path=path)
930 # Fallback: try tag as direct station_id (e.g. "genre:allrock" passed verbatim)
931 if ":" in tag:
932 return await self._browse_wave_station(tag, path=path)
933 return []
934
935 # waves/<category>/ â show station folders with artwork
936 if category and not tag:
937 cat_stations = categorized.get(category, [])
938 folders = []
939 for station_id, station_name, image_url in cat_stations:
940 tag_part = station_id.split(":", 1)[1] if ":" in station_id else station_id
941 station_image: MediaItemImage | None = None
942 if image_url:
943 station_image = MediaItemImage(
944 type=ImageType.THUMB,
945 path=image_url,
946 provider=self.instance_id,
947 remotely_accessible=True,
948 )
949 folders.append(
950 BrowseFolder(
951 item_id=station_id,
952 provider=self.instance_id,
953 path=f"{base}{tag_part}",
954 name=station_name,
955 is_playable=True,
956 image=station_image,
957 )
958 )
959 return folders
960
961 # waves/<category>/<tag>[/next] â stream tracks from rotor station
962 if category and tag:
963 station_id = f"{category}:{tag}"
964 return await self._browse_wave_station(station_id, path=path)
965
966 return []
967
968 @use_cache(600)
969 async def _get_dashboard_stations_cached(self) -> list[tuple[str, str, str | None]]:
970 """Get personalized dashboard stations, cached for 10 minutes.
971
972 :return: List of (station_id, name, image_url) tuples.
973 """
974 return await self.client.get_dashboard_stations()
975
976 async def _browse_my_waves_stations(self, path: str) -> list[BrowseFolder]:
977 """Browse personalized wave stations from rotor/stations/dashboard.
978
979 Names are resolved from the non-personalized station list so that
980 stations show their actual genre/mood name (e.g. "Рок") rather than
981 the generic "ÐÐ¾Ñ Ð²Ð¾Ð»Ð½Ð°" label that the dashboard API returns.
982
983 :param path: Full browse path (used to build sub-paths).
984 :return: List of playable BrowseFolder items, one per station.
985 """
986 stations = await self._get_dashboard_stations_cached()
987
988 # Build a name map from the non-personalized list for proper localized names.
989 locale = (self.mass.metadata.locale or "en_US").lower()
990 language = "ru" if locale.startswith("ru") else "en"
991 all_stations = await self.client.get_wave_stations(language)
992 station_name_map: dict[str, str] = {sid: name for sid, _, name, _ in all_stations}
993
994 base = path.rstrip("/") + "/"
995 folders: list[BrowseFolder] = []
996 for station_id, fallback_name, image_url in stations:
997 # Use full station_id (e.g. "genre:rock") in path to avoid collisions
998 # when two stations share the same tag but differ by category.
999 # The routing fallback (if ":" in tag) handles this correctly.
1000 name = station_name_map.get(station_id, fallback_name)
1001 station_image: MediaItemImage | None = None
1002 if image_url:
1003 station_image = MediaItemImage(
1004 type=ImageType.THUMB,
1005 path=image_url,
1006 provider=self.instance_id,
1007 remotely_accessible=True,
1008 )
1009 folders.append(
1010 BrowseFolder(
1011 item_id=station_id,
1012 provider=self.instance_id,
1013 path=f"{base}{station_id}",
1014 name=name,
1015 is_playable=True,
1016 image=station_image,
1017 )
1018 )
1019 return folders
1020
1021 async def _browse_wave_station(
1022 self, station_id: str, path: str = ""
1023 ) -> list[Track | BrowseFolder]:
1024 """Browse a rotor wave station and return tracks.
1025
1026 Fetches tracks from the rotor station, deduplicates within the current session,
1027 and sends radioStarted feedback on first call. Appends a "Load more" BrowseFolder
1028 at the end so MA can continue fetching the next batch automatically (radio mode).
1029
1030 :param station_id: Rotor station ID (e.g. 'genre:rock', 'mood:chill').
1031 :param path: Current browse path, used to construct the "Load more" next path.
1032 :return: List of Track objects with composite item_id (track_id@station_id),
1033 followed by a "Load more" BrowseFolder if more tracks are available.
1034 """
1035 state = self._get_wave_state(station_id)
1036 async with state.lock:
1037 max_tracks = int(
1038 self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type]
1039 )
1040
1041 self.logger.debug(
1042 "Browse wave station: station_id=%s path=%s last_track_id=%s",
1043 station_id,
1044 path,
1045 state.last_track_id,
1046 )
1047 yandex_tracks, batch_id = await self.client.get_rotor_station_tracks(
1048 station_id, queue=state.last_track_id
1049 )
1050 if batch_id:
1051 state.batch_id = batch_id
1052
1053 if not state.radio_started_sent and yandex_tracks:
1054 sent = await self.client.send_rotor_station_feedback(
1055 station_id,
1056 "radioStarted",
1057 batch_id=batch_id,
1058 )
1059 if sent:
1060 state.radio_started_sent = True
1061
1062 tracks: list[Track] = []
1063 first_track_id: str | None = None
1064 for yt in yandex_tracks:
1065 if len(state.seen_track_ids) >= max_tracks:
1066 break
1067 track = self._parse_my_wave_track(yt, state.seen_track_ids)
1068 if track is None:
1069 continue
1070 # Override station_id in composite item_id to reflect this specific station
1071 old_item_id = track.item_id
1072 track_id = old_item_id.split(RADIO_TRACK_ID_SEP, 1)[0]
1073 track.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{station_id}"
1074 # Keep provider mappings in sync with the new item_id
1075 for pm in getattr(track, "provider_mappings", []):
1076 if (
1077 getattr(pm, "item_id", None) == old_item_id
1078 and getattr(pm, "provider_instance", None) == self.instance_id
1079 ):
1080 pm.item_id = track.item_id
1081 if first_track_id is None:
1082 first_track_id = track_id
1083 tracks.append(track)
1084
1085 if first_track_id is not None:
1086 state.last_track_id = first_track_id
1087
1088 self.logger.debug(
1089 "Wave station %s returned %d tracks: %s",
1090 station_id,
1091 len(tracks),
1092 [t.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] for t in tracks[:5]],
1093 )
1094 result: list[Track | BrowseFolder] = list(tracks)
1095
1096 # Append "Load more" sentinel so MA knows to call browse again for next batch.
1097 # This mirrors the My Wave mechanism and enables continuous radio playback.
1098 if tracks and len(state.seen_track_ids) < max_tracks and path:
1099 names = self._get_browse_names()
1100 next_name = "ÐÑÑ" if names == BROWSE_NAMES_RU else "Load more"
1101 # Append /next to the current path (same pattern as _browse_my_wave).
1102 # This makes each "Load more" path unique (e.g. /next/next/next...)
1103 # so MA never serves a cached result for subsequent presses.
1104 result.append(
1105 BrowseFolder(
1106 item_id="next",
1107 provider=self.instance_id,
1108 path=f"{path.rstrip('/')}/next",
1109 name=next_name,
1110 is_playable=False,
1111 )
1112 )
1113
1114 return result
1115
1116 @staticmethod
1117 def _extract_wave_item_cover(item: dict[str, Any]) -> tuple[str | None, str | None]:
1118 """Extract cover URI and background color from a wave/mix item.
1119
1120 :param item: Wave or mix item dict from the API.
1121 :return: (cover_uri, bg_color) tuple where bg_color is a hex string or None.
1122 """
1123 agent_uri = item.get("agent", {}).get("cover", {}).get("uri", "")
1124 cover_uri = agent_uri or item.get("compact_image_url")
1125 bg_color = item.get("colors", {}).get("average")
1126 return cover_uri, bg_color
1127
1128 @use_cache(3600)
1129 async def _get_mixes_waves_cached(self) -> list[dict[str, Any]] | None:
1130 """Get AI Wave Set data from /landing-blocks/mixes-waves, cached for 1 hour.
1131
1132 :return: List of mix category dicts from the API, or None on error.
1133 """
1134 return await self.client.get_mixes_waves()
1135
1136 @use_cache(3600)
1137 async def _get_waves_landing_cached(self) -> list[dict[str, Any]] | None:
1138 """Get Featured Waves data from /landing-blocks/waves, cached for 1 hour.
1139
1140 :return: List of wave category dicts from the API, or None on error.
1141 """
1142 return await self.client.get_waves_landing()
1143
1144 async def _browse_waves_landing(
1145 self, path: str, path_parts: list[str]
1146 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
1147 """Browse Featured Waves (from /landing-blocks/waves).
1148
1149 :param path: Full browse path.
1150 :param path_parts: Split path parts after ://.
1151 :return: List of folders or tracks.
1152 """
1153 waves_data = await self._get_waves_landing_cached()
1154 return await self._browse_wave_categories(
1155 path, path_parts, waves_data or [], WAVES_LANDING_FOLDER_ID
1156 )
1157
1158 async def _browse_wave_categories(
1159 self,
1160 path: str,
1161 path_parts: list[str],
1162 categories_data: list[dict[str, Any]],
1163 id_prefix: str,
1164 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
1165 """Browse wave-like category folders and their station items.
1166
1167 Shared logic for both 'my_waves_set' browse trees:
1168 - Level 1 (e.g. my_waves_set/): category folders
1169 - Level 2 (e.g. my_waves_set/ai-sets/): playable station folders with artwork
1170 - Level 3+ (e.g. my_waves_set/ai-sets/genre:rock[/next]): track listing
1171
1172 :param path: Full browse path.
1173 :param path_parts: Split path parts after ://.
1174 :param categories_data: List of category dicts from the API.
1175 :param id_prefix: Prefix for BrowseFolder item_id (e.g. 'my_waves_set').
1176 :return: List of folders or tracks.
1177 """
1178 base = path.rstrip("/") + "/"
1179
1180 if not categories_data:
1181 return []
1182
1183 # Level 1 â category folders
1184 if len(path_parts) == 1:
1185 folders: list[BrowseFolder] = []
1186 for wave_category in categories_data:
1187 cat_id = wave_category.get("id", "")
1188 cat_title = wave_category.get("title", "")
1189 items = wave_category.get("items", [])
1190 if not items or not cat_id:
1191 continue
1192 display_name = cat_title.capitalize() if cat_title else cat_id.capitalize()
1193 folders.append(
1194 BrowseFolder(
1195 item_id=f"{id_prefix}_{cat_id}",
1196 provider=self.instance_id,
1197 path=f"{base}{cat_id}",
1198 name=display_name,
1199 is_playable=False,
1200 )
1201 )
1202 return folders
1203
1204 category_id = path_parts[1] if len(path_parts) > 1 else None
1205 if not category_id:
1206 return []
1207
1208 # Level 3+ â stream tracks from rotor station
1209 if len(path_parts) > 2:
1210 station_id = path_parts[2]
1211 return await self._browse_wave_station(station_id, path=path)
1212
1213 # Level 2 â playable station folders with artwork
1214 for wave_category in categories_data:
1215 if wave_category.get("id") == category_id:
1216 items = wave_category.get("items", [])
1217 result: list[BrowseFolder] = []
1218 for item in items:
1219 station_id = item.get("station_id", "")
1220 title = item.get("title", "")
1221 if not station_id or not title:
1222 continue
1223 cover_uri, bg_color = self._extract_wave_item_cover(item)
1224 image: MediaItemImage | None = None
1225 if cover_uri:
1226 if cover_uri.startswith("http"):
1227 img_url: str = cover_uri.replace("%%", IMAGE_SIZE_MEDIUM)
1228 else:
1229 raw = get_image_url(cover_uri)
1230 img_url = "" if raw is None else raw
1231 if img_url:
1232 if bg_color:
1233 # Append bg_color as URL fragment for cache-key uniqueness.
1234 # MA will call resolve_image() to composite the transparent PNG.
1235 if len(self._wave_bg_colors) > 200:
1236 self._wave_bg_colors.clear()
1237 img_url = f"{img_url}#{bg_color.lstrip('#')}"
1238 self._wave_bg_colors[img_url] = bg_color
1239 image = MediaItemImage(
1240 type=ImageType.THUMB,
1241 path=img_url,
1242 provider=self.instance_id,
1243 remotely_accessible=bg_color is None,
1244 )
1245 result.append(
1246 BrowseFolder(
1247 item_id=station_id,
1248 provider=self.instance_id,
1249 path=f"{base}{station_id}",
1250 name=title,
1251 is_playable=True,
1252 image=image,
1253 )
1254 )
1255 return result
1256
1257 return []
1258
1259 async def _browse_vibe_sets(
1260 self, path: str, path_parts: list[str]
1261 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
1262 """Browse AI Wave Sets (from /landing-blocks/mixes-waves).
1263
1264 :param path: Full browse path.
1265 :param path_parts: Split path parts after ://.
1266 :return: List of folders or tracks.
1267 """
1268 mixes_data = await self._get_mixes_waves_cached()
1269 return await self._browse_wave_categories(
1270 path, path_parts, mixes_data or [], MY_WAVES_SET_FOLDER_ID
1271 )
1272
1273 @use_cache(600)
1274 async def _get_tag_playlists_as_browse(
1275 self, tag_id: str
1276 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
1277 """Get playlists for a tag and return as browse items.
1278
1279 :param tag_id: Tag identifier (e.g. 'chill', '80s').
1280 :return: List of Playlist objects.
1281 """
1282 self.logger.debug("Fetching playlists for tag: %s", tag_id)
1283 playlists = await self.client.get_tag_playlists(tag_id)
1284 self.logger.debug("Got %d playlists for tag %s", len(playlists), tag_id)
1285 result: list[Playlist] = []
1286 for playlist in playlists:
1287 try:
1288 result.append(parse_playlist(self, playlist))
1289 except InvalidDataError as err:
1290 self.logger.debug("Error parsing tag playlist: %s", err)
1291 self.logger.debug("Parsed %d playlists for tag %s", len(result), tag_id)
1292 return result
1293
1294 # Search
1295
1296 @use_cache(3600 * 24 * 14)
1297 async def search(
1298 self, search_query: str, media_types: list[MediaType], limit: int = 5
1299 ) -> SearchResults:
1300 """Perform search on Yandex Music.
1301
1302 :param search_query: The search query.
1303 :param media_types: List of media types to search for.
1304 :param limit: Maximum number of results per type.
1305 :return: SearchResults with found items.
1306 """
1307 result = SearchResults()
1308
1309 # Determine search type based on requested media types
1310 # Map MediaType to Yandex API search type
1311 type_mapping = {
1312 MediaType.TRACK: "track",
1313 MediaType.ALBUM: "album",
1314 MediaType.ARTIST: "artist",
1315 MediaType.PLAYLIST: "playlist",
1316 }
1317 requested_types = [type_mapping[mt] for mt in media_types if mt in type_mapping]
1318
1319 # Use specific type if only one requested, otherwise search all
1320 search_type = requested_types[0] if len(requested_types) == 1 else "all"
1321
1322 search_result = await self.client.search(search_query, search_type=search_type, limit=limit)
1323 if not search_result:
1324 return result
1325
1326 # Parse tracks
1327 if MediaType.TRACK in media_types and search_result.tracks:
1328 for track in search_result.tracks.results[:limit]:
1329 try:
1330 result.tracks = [*result.tracks, parse_track(self, track)]
1331 except InvalidDataError as err:
1332 self.logger.debug("Error parsing track: %s", err)
1333
1334 # Parse albums
1335 if MediaType.ALBUM in media_types and search_result.albums:
1336 for album in search_result.albums.results[:limit]:
1337 try:
1338 result.albums = [*result.albums, parse_album(self, album)]
1339 except InvalidDataError as err:
1340 self.logger.debug("Error parsing album: %s", err)
1341
1342 # Parse artists
1343 if MediaType.ARTIST in media_types and search_result.artists:
1344 for artist in search_result.artists.results[:limit]:
1345 try:
1346 result.artists = [*result.artists, parse_artist(self, artist)]
1347 except InvalidDataError as err:
1348 self.logger.debug("Error parsing artist: %s", err)
1349
1350 # Parse playlists
1351 if MediaType.PLAYLIST in media_types and search_result.playlists:
1352 for playlist in search_result.playlists.results[:limit]:
1353 try:
1354 result.playlists = [*result.playlists, parse_playlist(self, playlist)]
1355 except InvalidDataError as err:
1356 self.logger.debug("Error parsing playlist: %s", err)
1357
1358 return result
1359
1360 # Get single items
1361
1362 @use_cache(3600 * 24 * 30)
1363 async def get_artist(self, prov_artist_id: str) -> Artist:
1364 """Get artist details by ID.
1365
1366 :param prov_artist_id: The provider artist ID.
1367 :return: Artist object.
1368 :raises MediaNotFoundError: If artist not found.
1369 """
1370 artist = await self.client.get_artist(prov_artist_id)
1371 if not artist:
1372 raise MediaNotFoundError(f"Artist {prov_artist_id} not found")
1373 return parse_artist(self, artist)
1374
1375 @use_cache(3600 * 24 * 30)
1376 async def get_album(self, prov_album_id: str) -> Album:
1377 """Get album details by ID.
1378
1379 :param prov_album_id: The provider album ID.
1380 :return: Album object.
1381 :raises MediaNotFoundError: If album not found.
1382 """
1383 album = await self.client.get_album(prov_album_id)
1384 if not album:
1385 raise MediaNotFoundError(f"Album {prov_album_id} not found")
1386 return parse_album(self, album)
1387
1388 async def get_track(self, prov_track_id: str) -> Track:
1389 """Get track details by ID.
1390
1391 Supports composite item_id (track_id@station_id) for My Wave tracks;
1392 only the track_id part is used for the API. Normalizes the ID before
1393 caching to avoid duplicate cache entries.
1394
1395 :param prov_track_id: The provider track ID (or track_id@station_id).
1396 :return: Track object.
1397 :raises MediaNotFoundError: If track not found.
1398 """
1399 track_id, _ = _parse_radio_item_id(prov_track_id)
1400 return await self._get_track_cached(track_id)
1401
1402 @use_cache(3600 * 24 * 30)
1403 async def _get_track_cached(self, track_id: str) -> Track:
1404 """Get track details by normalized ID (cached).
1405
1406 :param track_id: Normalized track ID (without station suffix).
1407 :return: Track object.
1408 :raises MediaNotFoundError: If track not found.
1409 """
1410 yandex_track = await self.client.get_track(track_id)
1411 if not yandex_track:
1412 raise MediaNotFoundError(f"Track {track_id} not found")
1413
1414 # Use the already-fetched track object to avoid a duplicate API call
1415 lyrics, lyrics_synced = await self.client.get_track_lyrics_from_track(yandex_track)
1416
1417 return parse_track(self, yandex_track, lyrics=lyrics, lyrics_synced=lyrics_synced)
1418
1419 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
1420 """Get playlist details by ID.
1421
1422 Supports virtual playlists MY_WAVE_PLAYLIST_ID (My Wave) and
1423 LIKED_TRACKS_PLAYLIST_ID (Liked Tracks). Real playlists use format "owner_id:kind".
1424
1425 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind",
1426 my_wave, or liked_tracks).
1427 :return: Playlist object.
1428 :raises MediaNotFoundError: If playlist not found.
1429 """
1430 # Virtual playlists - not cached (locale-dependent names)
1431 if prov_playlist_id == MY_WAVE_PLAYLIST_ID:
1432 names = self._get_browse_names()
1433 return Playlist(
1434 item_id=MY_WAVE_PLAYLIST_ID,
1435 provider=self.instance_id,
1436 name=names[MY_WAVE_PLAYLIST_ID],
1437 owner=get_canonical_provider_name(self),
1438 provider_mappings={
1439 ProviderMapping(
1440 item_id=MY_WAVE_PLAYLIST_ID,
1441 provider_domain=self.domain,
1442 provider_instance=self.instance_id,
1443 is_unique=True,
1444 )
1445 },
1446 is_editable=False,
1447 )
1448
1449 if prov_playlist_id == LIKED_TRACKS_PLAYLIST_ID:
1450 names = self._get_browse_names()
1451 return Playlist(
1452 item_id=LIKED_TRACKS_PLAYLIST_ID,
1453 provider=self.instance_id,
1454 name=names[LIKED_TRACKS_PLAYLIST_ID],
1455 owner=get_canonical_provider_name(self),
1456 provider_mappings={
1457 ProviderMapping(
1458 item_id=LIKED_TRACKS_PLAYLIST_ID,
1459 provider_domain=self.domain,
1460 provider_instance=self.instance_id,
1461 is_unique=True,
1462 )
1463 },
1464 is_editable=False,
1465 )
1466
1467 # Real playlists - use cached method
1468 return await self._get_real_playlist(prov_playlist_id)
1469
1470 @use_cache(3600 * 24 * 30)
1471 async def _get_real_playlist(self, prov_playlist_id: str) -> Playlist:
1472 """Get real playlist details by ID (cached).
1473
1474 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind").
1475 :return: Playlist object.
1476 :raises MediaNotFoundError: If playlist not found.
1477 """
1478 # Parse the playlist ID (format: owner_id:kind)
1479 if PLAYLIST_ID_SPLITTER in prov_playlist_id:
1480 owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
1481 else:
1482 owner_id = str(self.client.user_id)
1483 kind = prov_playlist_id
1484
1485 playlist = await self.client.get_playlist(owner_id, kind)
1486 if not playlist:
1487 raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found")
1488 return parse_playlist(self, playlist)
1489
1490 async def _get_my_wave_playlist_tracks(self, page: int) -> list[Track]:
1491 """Get My Wave tracks for virtual playlist (uncached; uses cursor for page > 0).
1492
1493 Fetches MY_WAVE_BATCH_SIZE Rotor API batches per page call to reduce
1494 the number of round-trips when the player controller paginates through pages.
1495
1496 :param page: Page number (0 = first batch, 1+ = next batches via queue cursor).
1497 :return: List of Track objects for this page.
1498 """
1499 async with self._my_wave_lock:
1500 max_tracks_config = int(
1501 self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type]
1502 )
1503
1504 # Reset seen tracks on first page
1505 if page == 0:
1506 self._my_wave_seen_track_ids = set()
1507
1508 queue: str | int | None = None
1509 if page > 0:
1510 queue = self._my_wave_playlist_next_cursor
1511 if not queue:
1512 return []
1513
1514 # Check if we've already reached the limit
1515 if len(self._my_wave_seen_track_ids) >= max_tracks_config:
1516 return []
1517
1518 tracks: list[Track] = []
1519 next_cursor: str | None = None
1520
1521 # Fetch MY_WAVE_BATCH_SIZE Rotor API batches per page to reduce API round-trips
1522 for _ in range(MY_WAVE_BATCH_SIZE):
1523 if len(self._my_wave_seen_track_ids) >= max_tracks_config:
1524 break
1525
1526 yandex_tracks, batch_id = await self.client.get_my_wave_tracks(queue=queue)
1527 if batch_id:
1528 self._my_wave_batch_id = batch_id
1529 if not self._my_wave_radio_started_sent and yandex_tracks:
1530 sent = await self.client.send_rotor_station_feedback(
1531 ROTOR_STATION_MY_WAVE,
1532 "radioStarted",
1533 batch_id=batch_id,
1534 )
1535 if sent:
1536 self._my_wave_radio_started_sent = True
1537
1538 if not yandex_tracks:
1539 break
1540
1541 first_track_id_this_batch = None
1542 for yt in yandex_tracks:
1543 if len(self._my_wave_seen_track_ids) >= max_tracks_config:
1544 break
1545
1546 track = self._parse_my_wave_track(yt, self._my_wave_seen_track_ids)
1547 if track is None:
1548 continue
1549
1550 tracks.append(track)
1551 track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0]
1552 if first_track_id_this_batch is None:
1553 first_track_id_this_batch = track_id
1554
1555 if first_track_id_this_batch is not None:
1556 next_cursor = first_track_id_this_batch
1557 queue = first_track_id_this_batch
1558 else:
1559 # All tracks in this batch were duplicates or failed to parse
1560 break
1561
1562 # Store cursor for next page call (None clears pagination so next call returns [])
1563 self._my_wave_playlist_next_cursor = next_cursor
1564 return tracks
1565
1566 async def _get_liked_tracks_playlist_tracks(self, page: int) -> list[Track]:
1567 """Get liked tracks for virtual playlist (sorted in reverse chronological order).
1568
1569 :param page: Page number (0 = all tracks limited by config, >0 = empty for pagination).
1570 :return: List of Track objects.
1571 """
1572 # Liked tracks API returns all tracks at once, so only return tracks on page 0
1573 if page > 0:
1574 return []
1575
1576 max_tracks_config = int(
1577 self.config.get_value(CONF_LIKED_TRACKS_MAX_TRACKS) or 500 # type: ignore[arg-type]
1578 )
1579
1580 # Fetch liked tracks (already sorted in reverse chronological order by api_client)
1581 track_shorts = await self.client.get_liked_tracks()
1582 if not track_shorts:
1583 self.logger.debug("No liked tracks found")
1584 return []
1585
1586 # Apply max tracks limit
1587 track_shorts = track_shorts[:max_tracks_config]
1588
1589 # Fetch full track details in batches
1590 track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id]
1591
1592 batch_size = TRACK_BATCH_SIZE
1593 full_tracks = []
1594 for i in range(0, len(track_ids), batch_size):
1595 batch_ids = track_ids[i : i + batch_size]
1596 batch_result = await self.client.get_tracks(batch_ids)
1597 full_tracks.extend(batch_result)
1598
1599 # Create track ID to full track mapping by track ID directly
1600 track_map = {}
1601 for t in full_tracks:
1602 if hasattr(t, "id") and t.id:
1603 track_map[str(t.id)] = t
1604
1605 # Parse tracks in the original order (reverse chronological)
1606 tracks = []
1607 for track_id in track_ids:
1608 # track_id may be compound "trackId:albumId", extract base ID for lookup
1609 base_id = track_id.split(":")[0] if ":" in track_id else track_id
1610 found = track_map.get(track_id) or track_map.get(base_id)
1611 if found:
1612 try:
1613 tracks.append(parse_track(self, found))
1614 except InvalidDataError as err:
1615 self.logger.debug("Error parsing liked track %s: %s", track_id, err)
1616
1617 self.logger.debug("Liked tracks: fetched %s, parsed %s", len(track_shorts), len(tracks))
1618 return tracks
1619
1620 # Get related items
1621
1622 @use_cache(3600 * 24 * 30)
1623 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
1624 """Get album tracks.
1625
1626 :param prov_album_id: The provider album ID.
1627 :return: List of Track objects.
1628 """
1629 album = await self.client.get_album_with_tracks(prov_album_id)
1630 if not album or not album.volumes:
1631 return []
1632
1633 tracks = []
1634 for volume_index, volume in enumerate(album.volumes):
1635 for track_index, track in enumerate(volume):
1636 try:
1637 parsed_track = parse_track(self, track)
1638 parsed_track.disc_number = volume_index + 1
1639 parsed_track.track_number = track_index + 1
1640 tracks.append(parsed_track)
1641 except InvalidDataError as err:
1642 self.logger.debug("Error parsing album track: %s", err)
1643 return tracks
1644
1645 @use_cache(3600 * 3)
1646 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
1647 """Get similar tracks using Yandex Rotor station for this track.
1648
1649 Uses rotor station track:{id} so MA radio mode gets Yandex recommendations.
1650
1651 :param prov_track_id: Provider track ID (plain or track_id@station_id).
1652 :param limit: Maximum number of tracks to return.
1653 :return: List of similar Track objects.
1654 """
1655 track_id, _ = _parse_radio_item_id(prov_track_id)
1656 station_id = f"track:{track_id}"
1657 yandex_tracks, _ = await self.client.get_rotor_station_tracks(station_id, queue=None)
1658 tracks = []
1659 for yt in yandex_tracks[:limit]:
1660 try:
1661 tracks.append(parse_track(self, yt))
1662 except InvalidDataError as err:
1663 self.logger.debug("Error parsing similar track: %s", err)
1664 return tracks
1665
1666 async def recommendations(self) -> list[RecommendationFolder]:
1667 """Get recommendations with multiple discovery folders.
1668
1669 Returns My Wave, Feed (Made for You), Chart, New Releases, and
1670 New Playlists sections.
1671
1672 :return: List of recommendation folders.
1673 """
1674 folders: list[RecommendationFolder] = []
1675
1676 folder = await self._get_my_wave_recommendations()
1677 if folder:
1678 folders.append(folder)
1679
1680 folder = await self._get_feed_recommendations()
1681 if folder:
1682 folders.append(folder)
1683
1684 folder = await self._get_chart_recommendations()
1685 if folder:
1686 folders.append(folder)
1687
1688 folder = await self._get_new_releases_recommendations()
1689 if folder:
1690 folders.append(folder)
1691
1692 folder = await self._get_new_playlists_recommendations()
1693 if folder:
1694 folders.append(folder)
1695
1696 # Picks & Mixes recommendations
1697 folder = await self._get_top_picks_recommendations()
1698 if folder:
1699 folders.append(folder)
1700
1701 # Mood mix: select tag outside cache so rotation actually works
1702 mood_tag = await self._pick_random_tag_for_category("mood")
1703 if mood_tag:
1704 folder = await self._get_mood_mix_recommendations(mood_tag)
1705 if folder:
1706 folders.append(folder)
1707
1708 # Activity mix: select tag outside cache so rotation actually works
1709 activity_tag = await self._pick_random_tag_for_category("activity")
1710 if activity_tag:
1711 folder = await self._get_activity_mix_recommendations(activity_tag)
1712 if folder:
1713 folders.append(folder)
1714
1715 folder = await self._get_seasonal_mix_recommendations()
1716 if folder:
1717 folders.append(folder)
1718
1719 return folders
1720
1721 @use_cache(600)
1722 async def _get_my_wave_recommendations(self) -> RecommendationFolder | None:
1723 """Get My Wave recommendation folder with personalized tracks.
1724
1725 :return: RecommendationFolder with My Wave tracks, or None if empty.
1726 """
1727 max_tracks_config = int(
1728 self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type]
1729 )
1730 batch_size_config = MY_WAVE_BATCH_SIZE
1731
1732 seen_track_ids: set[str] = set()
1733 items: list[Track] = []
1734 queue: str | int | None = None
1735
1736 for _ in range(batch_size_config):
1737 if len(seen_track_ids) >= max_tracks_config:
1738 break
1739
1740 yandex_tracks, _ = await self.client.get_my_wave_tracks(queue=queue)
1741 if not yandex_tracks:
1742 break
1743
1744 first_track_id_this_batch = None
1745 for yt in yandex_tracks:
1746 if len(seen_track_ids) >= max_tracks_config:
1747 break
1748
1749 track = self._parse_my_wave_track(yt, seen_ids=seen_track_ids)
1750 if track is None:
1751 continue
1752
1753 items.append(track)
1754 track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0]
1755 if first_track_id_this_batch is None:
1756 first_track_id_this_batch = track_id
1757
1758 queue = first_track_id_this_batch
1759 if not queue:
1760 break
1761
1762 if not items:
1763 return None
1764
1765 initial_tracks_limit = DISCOVERY_INITIAL_TRACKS
1766 if len(items) > initial_tracks_limit:
1767 items = items[:initial_tracks_limit]
1768
1769 names = self._get_browse_names()
1770 return RecommendationFolder(
1771 item_id=MY_WAVE_PLAYLIST_ID,
1772 provider=self.instance_id,
1773 name=names[MY_WAVE_PLAYLIST_ID],
1774 items=UniqueList(items),
1775 icon="mdi-waveform",
1776 )
1777
1778 @use_cache(1800)
1779 async def _get_feed_recommendations(self) -> RecommendationFolder | None:
1780 """Get personalized feed playlists (Playlist of the Day, DejaVu, etc.).
1781
1782 :return: RecommendationFolder with generated playlists, or None if unavailable.
1783 """
1784 feed = await self.client.get_feed()
1785 if not feed or not feed.generated_playlists:
1786 return None
1787 items: list[Playlist] = []
1788 for gen_playlist in feed.generated_playlists:
1789 if gen_playlist.data and gen_playlist.ready:
1790 try:
1791 items.append(parse_playlist(self, gen_playlist.data))
1792 except InvalidDataError as err:
1793 self.logger.debug("Error parsing feed playlist: %s", err)
1794 if not items:
1795 return None
1796 names = self._get_browse_names()
1797 return RecommendationFolder(
1798 item_id="feed",
1799 provider=self.instance_id,
1800 name=names["feed"],
1801 items=UniqueList(items),
1802 icon="mdi-account-music",
1803 )
1804
1805 @use_cache(3600)
1806 async def _get_chart_recommendations(self) -> RecommendationFolder | None:
1807 """Get chart tracks (hot tracks of the month).
1808
1809 :return: RecommendationFolder with chart tracks, or None if unavailable.
1810 """
1811 chart_info = await self.client.get_chart()
1812 if not chart_info or not chart_info.chart:
1813 return None
1814 playlist = chart_info.chart
1815 if not playlist.tracks:
1816 return None
1817 # TrackShort objects in chart context have .track (full Track) and .chart (position)
1818 tracks: list[Track] = []
1819 for track_short in playlist.tracks[:20]:
1820 track_obj = getattr(track_short, "track", None)
1821 if not track_obj:
1822 continue
1823 try:
1824 tracks.append(parse_track(self, track_obj))
1825 except InvalidDataError as err:
1826 self.logger.debug("Error parsing chart track: %s", err)
1827 if not tracks:
1828 return None
1829 names = self._get_browse_names()
1830 return RecommendationFolder(
1831 item_id="chart",
1832 provider=self.instance_id,
1833 name=names["chart"],
1834 items=UniqueList(tracks),
1835 icon="mdi-chart-line",
1836 )
1837
1838 @use_cache(3600)
1839 async def _get_new_releases_recommendations(self) -> RecommendationFolder | None:
1840 """Get new album releases.
1841
1842 :return: RecommendationFolder with new albums, or None if unavailable.
1843 """
1844 releases = await self.client.get_new_releases()
1845 if not releases or not releases.new_releases:
1846 return None
1847 # new_releases is a list of album IDs (int) â need to batch-fetch full details
1848 album_ids = [str(aid) for aid in releases.new_releases[:20]]
1849 if not album_ids:
1850 return None
1851 full_albums = await self.client.get_albums(album_ids)
1852 if not full_albums:
1853 return None
1854 albums: list[Album] = []
1855 for album in full_albums:
1856 try:
1857 albums.append(parse_album(self, album))
1858 except InvalidDataError as err:
1859 self.logger.debug("Error parsing new release album: %s", err)
1860 if not albums:
1861 return None
1862 names = self._get_browse_names()
1863 return RecommendationFolder(
1864 item_id="new_releases",
1865 provider=self.instance_id,
1866 name=names["new_releases"],
1867 items=UniqueList(albums),
1868 icon="mdi-new-box",
1869 )
1870
1871 @use_cache(3600)
1872 async def _get_new_playlists_recommendations(self) -> RecommendationFolder | None:
1873 """Get new editorial playlists.
1874
1875 :return: RecommendationFolder with new playlists, or None if unavailable.
1876 """
1877 result = await self.client.get_new_playlists()
1878 if not result or not result.new_playlists:
1879 return None
1880 # new_playlists is a list of PlaylistId objects (uid, kind) â fetch full details
1881 playlist_ids = [
1882 f"{pid.uid}:{pid.kind}"
1883 for pid in result.new_playlists[:20]
1884 if hasattr(pid, "uid") and hasattr(pid, "kind")
1885 ]
1886 if not playlist_ids:
1887 return None
1888 full_playlists = await self.client.get_playlists(playlist_ids)
1889 if not full_playlists:
1890 return None
1891 playlists: list[Playlist] = []
1892 for playlist in full_playlists:
1893 try:
1894 playlists.append(parse_playlist(self, playlist))
1895 except InvalidDataError as err:
1896 self.logger.debug("Error parsing new playlist: %s", err)
1897 if not playlists:
1898 return None
1899 names = self._get_browse_names()
1900 return RecommendationFolder(
1901 item_id="new_playlists",
1902 provider=self.instance_id,
1903 name=names["new_playlists"],
1904 items=UniqueList(playlists),
1905 icon="mdi-playlist-star",
1906 )
1907
1908 @use_cache(3600)
1909 async def _get_top_picks_recommendations(self) -> RecommendationFolder | None:
1910 """Get Top Picks recommendation folder (tag: top).
1911
1912 :return: RecommendationFolder with top playlists, or None if unavailable.
1913 """
1914 playlists = await self.client.get_tag_playlists("top")
1915 if not playlists:
1916 return None
1917 items: list[Playlist] = []
1918 for playlist in playlists[:10]:
1919 try:
1920 items.append(parse_playlist(self, playlist))
1921 except InvalidDataError as err:
1922 self.logger.debug("Error parsing top picks playlist: %s", err)
1923 if not items:
1924 return None
1925 names = self._get_browse_names()
1926 return RecommendationFolder(
1927 item_id="top_picks",
1928 provider=self.instance_id,
1929 name=names.get("top_picks", "Top Picks"),
1930 items=UniqueList(items),
1931 icon="mdi-star",
1932 )
1933
1934 async def _pick_random_tag_for_category(self, category: str) -> str | None:
1935 """Pick a random valid tag for a category (not cached â enables rotation).
1936
1937 :param category: Category name ('mood', 'activity', etc.).
1938 :return: Random tag slug, or None if no valid tags.
1939 """
1940 valid_tags = await self._get_valid_tags_for_category(category)
1941 if not valid_tags:
1942 return None
1943 return random.choice(valid_tags)
1944
1945 @use_cache(1800)
1946 async def _get_mood_mix_recommendations(self, mood_tag: str) -> RecommendationFolder | None:
1947 """Get Mood Mix recommendation folder for a specific tag.
1948
1949 :param mood_tag: Pre-selected mood tag slug.
1950 :return: RecommendationFolder with mood playlists, or None if unavailable.
1951 """
1952 playlists = await self.client.get_tag_playlists(mood_tag)
1953 if not playlists:
1954 self.logger.debug("No playlists for mood tag %s, skipping recommendation", mood_tag)
1955 return None
1956 items: list[Playlist] = []
1957 for playlist in playlists[:8]:
1958 try:
1959 items.append(parse_playlist(self, playlist))
1960 except InvalidDataError as err:
1961 self.logger.debug("Error parsing mood playlist: %s", err)
1962 if not items:
1963 return None
1964 names = self._get_browse_names()
1965 tag_name = names.get(mood_tag, mood_tag.title())
1966 return RecommendationFolder(
1967 item_id="mood_mix",
1968 provider=self.instance_id,
1969 name=f"{names.get('mood_mix', 'Mood')}: {tag_name}",
1970 items=UniqueList(items),
1971 icon="mdi-emoticon-outline",
1972 )
1973
1974 @use_cache(1800)
1975 async def _get_activity_mix_recommendations(
1976 self, activity_tag: str
1977 ) -> RecommendationFolder | None:
1978 """Get Activity Mix recommendation folder for a specific tag.
1979
1980 :param activity_tag: Pre-selected activity tag slug.
1981 :return: RecommendationFolder with activity playlists, or None if unavailable.
1982 """
1983 playlists = await self.client.get_tag_playlists(activity_tag)
1984 if not playlists:
1985 self.logger.debug(
1986 "No playlists for activity tag %s, skipping recommendation", activity_tag
1987 )
1988 return None
1989 items: list[Playlist] = []
1990 for playlist in playlists[:8]:
1991 try:
1992 items.append(parse_playlist(self, playlist))
1993 except InvalidDataError as err:
1994 self.logger.debug("Error parsing activity playlist: %s", err)
1995 if not items:
1996 return None
1997 names = self._get_browse_names()
1998 tag_name = names.get(activity_tag, activity_tag.title())
1999 return RecommendationFolder(
2000 item_id="activity_mix",
2001 provider=self.instance_id,
2002 name=f"{names.get('activity_mix', 'Activity')}: {tag_name}",
2003 items=UniqueList(items),
2004 icon="mdi-run",
2005 )
2006
2007 @use_cache(3600 * 6)
2008 async def _get_seasonal_mix_recommendations(self) -> RecommendationFolder | None:
2009 """Get Seasonal Mix recommendation folder (based on current month).
2010
2011 :return: RecommendationFolder with seasonal playlists, or None if unavailable.
2012 """
2013 # Determine current season tag
2014 current_month = datetime.now(tz=UTC).month
2015 seasonal_tag = TAG_SEASONAL_MAP.get(current_month, "autumn")
2016
2017 # Validate the seasonal tag; fall back to autumn if not available
2018 if not await self._validate_tag(seasonal_tag):
2019 seasonal_tag = "autumn"
2020
2021 playlists = await self.client.get_tag_playlists(seasonal_tag)
2022 if not playlists:
2023 return None
2024 items: list[Playlist] = []
2025 for playlist in playlists[:8]:
2026 try:
2027 items.append(parse_playlist(self, playlist))
2028 except InvalidDataError as err:
2029 self.logger.debug("Error parsing seasonal playlist: %s", err)
2030 if not items:
2031 return None
2032 names = self._get_browse_names()
2033 tag_name = names.get(seasonal_tag, seasonal_tag.title())
2034 return RecommendationFolder(
2035 item_id="seasonal_mix",
2036 provider=self.instance_id,
2037 name=f"{names.get('seasonal_mix', 'Seasonal')}: {tag_name}",
2038 items=UniqueList(items),
2039 icon="mdi-weather-sunny",
2040 )
2041
2042 @use_cache(3600 * 3)
2043 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
2044 """Get playlist tracks.
2045
2046 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind",
2047 my_wave, or liked_tracks).
2048 :param page: Page number for pagination.
2049 :return: List of Track objects.
2050 """
2051 self.logger.debug(
2052 "get_playlist_tracks called: prov_playlist_id=%s, page=%s", prov_playlist_id, page
2053 )
2054
2055 if prov_playlist_id == MY_WAVE_PLAYLIST_ID:
2056 self.logger.debug("Fetching My Wave tracks")
2057 return await self._get_my_wave_playlist_tracks(page)
2058
2059 if prov_playlist_id == LIKED_TRACKS_PLAYLIST_ID:
2060 self.logger.debug("Fetching Liked Tracks for virtual playlist")
2061 result = await self._get_liked_tracks_playlist_tracks(page)
2062 self.logger.debug("Liked Tracks playlist returned %s tracks", len(result))
2063 return result
2064
2065 # Yandex Music API returns all playlist tracks in one call (no server-side pagination).
2066 # Return empty list for page > 0 so the controller pagination loop terminates.
2067 if page > 0:
2068 return []
2069
2070 # Parse the playlist ID (format: owner_id:kind)
2071 if PLAYLIST_ID_SPLITTER in prov_playlist_id:
2072 owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
2073 else:
2074 owner_id = str(self.client.user_id)
2075 kind = prov_playlist_id
2076
2077 playlist = await self.client.get_playlist(owner_id, kind)
2078 if not playlist:
2079 return []
2080
2081 # API sometimes returns playlist without tracks; fetch them explicitly if needed
2082 tracks_list = playlist.tracks or []
2083 track_count = getattr(playlist, "track_count", None) or 0
2084 if not tracks_list and track_count > 0:
2085 self.logger.debug(
2086 "Playlist %s/%s: track_count=%s but no tracks in response, "
2087 "calling fetch_tracks_async",
2088 owner_id,
2089 kind,
2090 track_count,
2091 )
2092 try:
2093 tracks_list = await playlist.fetch_tracks_async()
2094 except Exception as err:
2095 self.logger.warning("fetch_tracks_async failed for %s/%s: %s", owner_id, kind, err)
2096 if not tracks_list:
2097 raise ResourceTemporarilyUnavailable(
2098 "Playlist tracks not available; try again later"
2099 )
2100
2101 if not tracks_list:
2102 return []
2103
2104 # Yandex returns TrackShort objects, we need to fetch full track info
2105 track_ids = [
2106 str(track.track_id) if hasattr(track, "track_id") else str(track.id)
2107 for track in tracks_list
2108 if track
2109 ]
2110 if not track_ids:
2111 return []
2112
2113 # Fetch full track details in batches to avoid timeouts
2114 batch_size = TRACK_BATCH_SIZE
2115 full_tracks = []
2116 for i in range(0, len(track_ids), batch_size):
2117 batch = track_ids[i : i + batch_size]
2118 batch_result = await self.client.get_tracks(batch)
2119 if not batch_result:
2120 self.logger.warning(
2121 "Received empty result for playlist %s tracks batch %s-%s",
2122 prov_playlist_id,
2123 i,
2124 i + len(batch) - 1,
2125 )
2126 raise ResourceTemporarilyUnavailable(
2127 "Playlist tracks not fully available; try again later"
2128 )
2129 full_tracks.extend(batch_result)
2130
2131 if track_ids and not full_tracks:
2132 raise ResourceTemporarilyUnavailable("Failed to load track details; try again later")
2133
2134 tracks = []
2135 for track in full_tracks:
2136 try:
2137 tracks.append(parse_track(self, track))
2138 except InvalidDataError as err:
2139 self.logger.debug("Error parsing playlist track: %s", err)
2140 return tracks
2141
2142 @use_cache(3600 * 24 * 7)
2143 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
2144 """Get artist's albums.
2145
2146 :param prov_artist_id: The provider artist ID.
2147 :return: List of Album objects.
2148 """
2149 albums = await self.client.get_artist_albums(prov_artist_id)
2150 result = []
2151 for album in albums:
2152 try:
2153 result.append(parse_album(self, album))
2154 except InvalidDataError as err:
2155 self.logger.debug("Error parsing artist album: %s", err)
2156 return result
2157
2158 @use_cache(3600 * 24 * 7)
2159 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
2160 """Get artist's top tracks.
2161
2162 :param prov_artist_id: The provider artist ID.
2163 :return: List of Track objects.
2164 """
2165 tracks = await self.client.get_artist_tracks(prov_artist_id)
2166 result = []
2167 for track in tracks:
2168 try:
2169 result.append(parse_track(self, track))
2170 except InvalidDataError as err:
2171 self.logger.debug("Error parsing artist track: %s", err)
2172 return result
2173
2174 # Library methods
2175
2176 async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
2177 """Retrieve library artists from Yandex Music."""
2178 artists = await self.client.get_liked_artists()
2179 for artist in artists:
2180 try:
2181 yield parse_artist(self, artist)
2182 except InvalidDataError as err:
2183 self.logger.debug("Error parsing library artist: %s", err)
2184
2185 async def get_library_albums(self) -> AsyncGenerator[Album, None]:
2186 """Retrieve library albums from Yandex Music."""
2187 batch_size = TRACK_BATCH_SIZE
2188 albums = await self.client.get_liked_albums(batch_size=batch_size)
2189 for album in albums:
2190 try:
2191 yield parse_album(self, album)
2192 except InvalidDataError as err:
2193 self.logger.debug("Error parsing library album: %s", err)
2194
2195 async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
2196 """Retrieve library tracks from Yandex Music."""
2197 track_shorts = await self.client.get_liked_tracks()
2198 if not track_shorts:
2199 return
2200
2201 # Fetch full track details in batches
2202 track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id]
2203 batch_size = TRACK_BATCH_SIZE
2204 for i in range(0, len(track_ids), batch_size):
2205 batch_ids = track_ids[i : i + batch_size]
2206 full_tracks = await self.client.get_tracks(batch_ids)
2207 for track in full_tracks:
2208 try:
2209 yield parse_track(self, track)
2210 except InvalidDataError as err:
2211 self.logger.debug("Error parsing library track: %s", err)
2212
2213 async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
2214 """Retrieve library playlists from Yandex Music.
2215
2216 Includes virtual playlists (My Wave and Liked Tracks if enabled), user-created playlists,
2217 and user-liked editorial playlists (returned by a separate API endpoint).
2218 """
2219 yield await self.get_playlist(MY_WAVE_PLAYLIST_ID)
2220 yield await self.get_playlist(LIKED_TRACKS_PLAYLIST_ID)
2221 seen_ids: set[str] = set()
2222 # User-created playlists
2223 playlists = await self.client.get_user_playlists()
2224 for playlist in playlists:
2225 try:
2226 parsed = parse_playlist(self, playlist)
2227 seen_ids.add(parsed.item_id)
2228 yield parsed
2229 except InvalidDataError as err:
2230 self.logger.debug("Error parsing library playlist: %s", err)
2231 # User-liked editorial playlists (not in users_playlists_list)
2232 liked_playlists = await self.client.get_liked_playlists()
2233 for playlist in liked_playlists:
2234 try:
2235 parsed = parse_playlist(self, playlist)
2236 if parsed.item_id not in seen_ids:
2237 yield parsed
2238 except InvalidDataError as err:
2239 self.logger.debug("Error parsing liked playlist: %s", err)
2240
2241 # Library edit methods
2242
2243 async def library_add(self, item: MediaItemType) -> bool:
2244 """Add item to library.
2245
2246 :param item: The media item to add.
2247 :return: True if successful.
2248 """
2249 prov_item_id = self._get_provider_item_id(item)
2250 if not prov_item_id:
2251 return False
2252 track_id, _ = _parse_radio_item_id(prov_item_id)
2253
2254 if item.media_type == MediaType.TRACK:
2255 return await self.client.like_track(track_id)
2256 if item.media_type == MediaType.ALBUM:
2257 return await self.client.like_album(prov_item_id)
2258 if item.media_type == MediaType.ARTIST:
2259 return await self.client.like_artist(prov_item_id)
2260 return False
2261
2262 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
2263 """Remove item from library.
2264
2265 :param prov_item_id: The provider item ID (may be track_id@station_id for tracks).
2266 :param media_type: The media type.
2267 :return: True if successful.
2268 """
2269 track_id, _ = _parse_radio_item_id(prov_item_id)
2270 if media_type == MediaType.TRACK:
2271 return await self.client.unlike_track(track_id)
2272 if media_type == MediaType.ALBUM:
2273 return await self.client.unlike_album(prov_item_id)
2274 if media_type == MediaType.ARTIST:
2275 return await self.client.unlike_artist(prov_item_id)
2276 return False
2277
2278 def _get_provider_item_id(self, item: MediaItemType) -> str | None:
2279 """Get provider item ID from media item."""
2280 for mapping in item.provider_mappings:
2281 if mapping.provider_instance == self.instance_id:
2282 return mapping.item_id
2283 return item.item_id if item.provider == self.instance_id else None
2284
2285 # Streaming
2286
2287 async def get_stream_details(
2288 self, item_id: str, media_type: MediaType = MediaType.TRACK
2289 ) -> StreamDetails:
2290 """Get stream details for a track.
2291
2292 :param item_id: The track ID (or track_id@station_id for My Wave).
2293 :param media_type: The media type (should be TRACK).
2294 :return: StreamDetails for the track.
2295 """
2296 return await self.streaming.get_stream_details(item_id)
2297
2298 async def get_audio_stream(
2299 self, streamdetails: StreamDetails, seek_position: int = 0
2300 ) -> AsyncGenerator[bytes, None]:
2301 """Return the audio stream for the provider item.
2302
2303 This method is called when StreamType.CUSTOM is used, enabling on-the-fly
2304 decryption of encrypted FLAC streams without disk I/O.
2305
2306 :param streamdetails: Stream details containing encrypted URL and decryption key.
2307 :param seek_position: Seek position in seconds (not supported for encrypted streams).
2308 :return: Async generator yielding decrypted audio chunks.
2309 """
2310 async for chunk in self.streaming.get_audio_stream(streamdetails, seek_position):
2311 yield chunk
2312
2313 async def resolve_image(self, path: str) -> str | bytes:
2314 """Resolve wave cover image with background color fill for transparent PNGs.
2315
2316 If the image URL has an associated background color (stored in _wave_bg_colors),
2317 downloads the PNG from Yandex CDN and composites it on a solid color background
2318 using Pillow, returning JPEG bytes. Falls back to the original URL on any error.
2319
2320 :param path: Image URL (may include #rrggbb fragment used as cache key).
2321 :return: Composited JPEG bytes, or original path string as fallback.
2322 """
2323 bg_color = self._wave_bg_colors.get(path)
2324 if not bg_color:
2325 return path
2326
2327 # Strip the #color fragment before fetching the actual image
2328 fetch_url = path.split("#", maxsplit=1)[0] if "#" in path else path
2329 try:
2330 async with self.mass.http_session.get(fetch_url) as resp:
2331 resp.raise_for_status()
2332 raw = await resp.read()
2333 except Exception as err:
2334 self.logger.debug("Failed to fetch wave cover %s: %s", fetch_url, err)
2335 return fetch_url
2336
2337 def _composite() -> bytes:
2338 bg_clean = bg_color.lstrip("#")
2339 try:
2340 r = int(bg_clean[0:2], 16)
2341 g = int(bg_clean[2:4], 16)
2342 b = int(bg_clean[4:6], 16)
2343 except (ValueError, IndexError):
2344 return raw
2345 fg = PilImage.open(BytesIO(raw)).convert("RGBA")
2346 bg = PilImage.new("RGBA", fg.size, (r, g, b, 255))
2347 bg.paste(fg, mask=fg)
2348 out = BytesIO()
2349 bg.convert("RGB").save(out, "JPEG", quality=92)
2350 return out.getvalue()
2351
2352 try:
2353 return await asyncio.to_thread(_composite)
2354 except Exception as err:
2355 self.logger.debug("Wave cover composite failed for %s: %s", fetch_url, err)
2356 return fetch_url
2357
2358 async def on_played(
2359 self,
2360 media_type: MediaType,
2361 prov_item_id: str,
2362 fully_played: bool,
2363 position: int,
2364 media_item: MediaItemType,
2365 is_playing: bool = False,
2366 ) -> None:
2367 """Report playback for rotor feedback when the track is from My Wave.
2368
2369 Sends trackStarted when the track is currently playing (is_playing=True).
2370 trackFinished/skip are sent from on_streamed to use accurate seconds_streamed.
2371
2372 Also auto-enables "Don't stop the music" for any queue playing a radio track
2373 so that MA refills the queue via get_similar_tracks when < 5 tracks remain.
2374 """
2375 # Radio feedback always enabled
2376 if media_type != MediaType.TRACK:
2377 return
2378 track_id, station_id = _parse_radio_item_id(prov_item_id)
2379 if not station_id:
2380 return
2381 # Auto-enable "Don't stop the music" on every on_played call for radio tracks.
2382 # Calling on every invocation (not just is_playing=True) ensures it fires even
2383 # for short tracks that finish before the 30-second periodic callback.
2384 self._ensure_dont_stop_the_music(prov_item_id)
2385 if is_playing:
2386 if station_id == ROTOR_STATION_MY_WAVE:
2387 batch_id = self._my_wave_batch_id
2388 else:
2389 state = self._wave_states.get(station_id)
2390 batch_id = state.batch_id if state else None
2391 await self.client.send_rotor_station_feedback(
2392 station_id,
2393 "trackStarted",
2394 track_id=track_id,
2395 batch_id=batch_id,
2396 )
2397 # Remove duplicate call that was under is_playing guard.
2398 # _ensure_dont_stop_the_music is now called unconditionally above.
2399
2400 def _ensure_dont_stop_the_music(self, prov_item_id: str) -> None:
2401 """Enable 'Don't stop the music' on queues playing this specific radio item.
2402
2403 Iterates all queues and enables the setting on queues whose current track
2404 mapping matches this exact composite item_id (track_id@station_id) for this
2405 provider instance.
2406
2407 Also sets queue.radio_source directly to the current track because
2408 enqueued_media_items is empty for BrowseFolder-initiated playback, which
2409 normally prevents MA's auto-fill from triggering. Setting radio_source
2410 directly bypasses that gap so _fill_radio_tracks runs when < 5 tracks remain.
2411 """
2412 for queue in self.mass.player_queues:
2413 current = queue.current_item
2414 if current is None or current.media_item is None:
2415 continue
2416 item = current.media_item
2417 # Match by provider instance and exact composite item_id
2418 for mapping in getattr(item, "provider_mappings", []):
2419 if (
2420 mapping.provider_instance == self.instance_id
2421 and mapping.item_id == prov_item_id
2422 ):
2423 # Set radio_source directly so MA's fill mechanism works even when
2424 # the queue was started from a BrowseFolder (enqueued_media_items empty).
2425 if not queue.radio_source and isinstance(item, Track):
2426 queue.radio_source = [item]
2427 if not queue.dont_stop_the_music_enabled:
2428 try:
2429 self.mass.player_queues.set_dont_stop_the_music(
2430 queue.queue_id, dont_stop_the_music_enabled=True
2431 )
2432 self.logger.info(
2433 "Auto-enabled 'Don't stop the music' for queue %s (radio station)",
2434 queue.display_name,
2435 )
2436 except Exception as err:
2437 self.logger.debug(
2438 "Could not enable 'Don't stop the music' for queue %s: %s",
2439 queue.display_name,
2440 err,
2441 )
2442 break
2443
2444 def _ensure_dont_stop_the_music_for_queue(self, queue_id: str | None) -> None:
2445 """Enable 'Don't stop the music' for a specific queue by ID.
2446
2447 Faster variant of _ensure_dont_stop_the_music used from on_streamed where
2448 queue_id is available directly, avoiding iteration over all queues.
2449 """
2450 if not queue_id:
2451 return
2452 queue = self.mass.player_queues.get(queue_id)
2453 if queue is None:
2454 return
2455 current = queue.current_item
2456 if current is None or current.media_item is None:
2457 return
2458 item = current.media_item
2459 for mapping in getattr(item, "provider_mappings", []):
2460 if (
2461 mapping.provider_instance == self.instance_id
2462 and RADIO_TRACK_ID_SEP in mapping.item_id
2463 ):
2464 if not queue.radio_source and isinstance(item, Track):
2465 queue.radio_source = [item]
2466 if not queue.dont_stop_the_music_enabled:
2467 try:
2468 self.mass.player_queues.set_dont_stop_the_music(
2469 queue_id, dont_stop_the_music_enabled=True
2470 )
2471 self.logger.info(
2472 "Auto-enabled 'Don't stop the music' for queue %s (radio)",
2473 queue.display_name,
2474 )
2475 except Exception as err:
2476 self.logger.debug(
2477 "Could not enable 'Don't stop the music' for queue %s: %s",
2478 queue.display_name,
2479 err,
2480 )
2481 break
2482
2483 async def on_streamed(self, streamdetails: StreamDetails) -> None:
2484 """Report stream completion for My Wave rotor feedback.
2485
2486 Sends trackFinished or skip with actual seconds_streamed so Yandex
2487 can improve recommendations.
2488 """
2489 # Radio feedback always enabled
2490 track_id, station_id = _parse_radio_item_id(streamdetails.item_id)
2491 if not station_id:
2492 return
2493 # Also ensure Don't stop the music is active â on_streamed fires even for
2494 # very short tracks and we have queue_id here directly.
2495 self._ensure_dont_stop_the_music_for_queue(streamdetails.queue_id)
2496 seconds = int(streamdetails.seconds_streamed or 0)
2497 duration = streamdetails.duration or 0
2498 feedback_type = "trackFinished" if duration and seconds >= max(0, duration - 10) else "skip"
2499 if station_id == ROTOR_STATION_MY_WAVE:
2500 batch_id = self._my_wave_batch_id
2501 else:
2502 state = self._wave_states.get(station_id)
2503 batch_id = state.batch_id if state else None
2504 await self.client.send_rotor_station_feedback(
2505 station_id,
2506 feedback_type,
2507 track_id=track_id,
2508 total_played_seconds=seconds,
2509 batch_id=batch_id,
2510 )
2511