/
/
/
1"""
2BBC Sounds music provider support for MusicAssistant.
3
4TODO implement seeking of live stream
5"""
6
7import asyncio
8from collections.abc import AsyncGenerator, Sequence
9from typing import TYPE_CHECKING, Literal
10
11from music_assistant_models.config_entries import (
12 ConfigEntry,
13 ConfigValueOption,
14 ProviderConfig,
15)
16from music_assistant_models.enums import ConfigEntryType, ImageType, MediaType, ProviderFeature
17from music_assistant_models.errors import LoginFailed, MediaNotFoundError, MusicAssistantError
18from music_assistant_models.media_items import (
19 BrowseFolder,
20 ItemMapping,
21 MediaItemImage,
22 MediaItemMetadata,
23 MediaItemType,
24 Podcast,
25 PodcastEpisode,
26 ProviderMapping,
27 Radio,
28 RecommendationFolder,
29 SearchResults,
30 Track,
31)
32from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
33from music_assistant_models.unique_list import UniqueList
34from sounds import (
35 Container,
36 LiveStation,
37 LoginFailedError,
38 Menu,
39 MenuRecommendationOptions,
40 PlayableItem,
41 PlayStatus,
42 RadioShow,
43 Segment,
44 SoundsClient,
45 exceptions,
46)
47from sounds import PodcastEpisode as SoundsPodcastEpisode
48from sounds.models import MenuItem, Playlist
49
50from music_assistant.constants import CONF_ENTRY_UNOFFICIAL_PROVIDER, CONF_PASSWORD, CONF_USERNAME
51from music_assistant.controllers.cache import use_cache
52from music_assistant.helpers.datetime import LOCAL_TIMEZONE
53from music_assistant.mass import MusicAssistant
54from music_assistant.models import ProviderInstanceType
55from music_assistant.models.music_provider import MusicProvider
56from music_assistant.models.recommendation_payload import RecommendationPayloadMixin
57from music_assistant.providers.bbc_sounds.adaptor import Adaptor
58from music_assistant.providers.bbc_sounds.constants import _Constants
59from music_assistant.providers.bbc_sounds.metadata import _find_segment, _segment_to_metadata
60
61if TYPE_CHECKING:
62 from music_assistant_models.provider import ProviderManifest
63 from sounds.models import SoundsTypes
64
65SUPPORTED_FEATURES = {
66 ProviderFeature.BROWSE,
67 ProviderFeature.RECOMMENDATIONS,
68 ProviderFeature.SEARCH,
69}
70
71type _StreamTypes = Literal["hls", "dash"]
72
73
74async def setup(
75 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
76) -> ProviderInstanceType:
77 """Create new provider instance."""
78 instance = BBCSoundsProvider(mass, manifest, config, SUPPORTED_FEATURES)
79 await instance.handle_async_init()
80 return instance
81
82
83class BBCSoundsProvider(RecommendationPayloadMixin, MusicProvider):
84 """A MusicProvider class to interact with the BBC Sounds API via auntie-sounds."""
85
86 # keep the pre-refactor 3h refresh interval for the experience-menu payload
87 recommendation_payload_ttl = 3600 * 3
88
89 client: SoundsClient
90 menu: Menu | None = None
91 logged_in: bool = False
92
93 @property
94 def max_concurrent_streams(self) -> None:
95 """Allow unlimited concurrent upstream source streams."""
96 return None
97
98 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
99 """Return Config entries to setup this provider."""
100 return (
101 CONF_ENTRY_UNOFFICIAL_PROVIDER,
102 ConfigEntry(
103 key=_Constants.CONF_INTRO,
104 type=ConfigEntryType.LABEL,
105 ),
106 ConfigEntry(
107 key=_Constants.CONF_SHOW_LOCAL,
108 advanced=True,
109 type=ConfigEntryType.BOOLEAN,
110 default_value=False,
111 ),
112 ConfigEntry(
113 key=_Constants.CONF_STREAM_FORMAT,
114 advanced=True,
115 type=ConfigEntryType.STRING,
116 options=[
117 ConfigValueOption(_Constants.CONF_STREAM_FORMAT_HLS),
118 ConfigValueOption(_Constants.CONF_STREAM_FORMAT_DASH),
119 ],
120 default_value=_Constants.CONF_STREAM_FORMAT_HLS,
121 ),
122 )
123
124 async def handle_async_init(self) -> None:
125 """Handle async initialization of the provider."""
126 # If we have an account, authenticate. Testing shows all features work without auth
127 # but BBC will be disabling BBC Sounds from outside the UK at some point
128 username = self.get_setup_value(CONF_USERNAME)
129 password = self.get_setup_value(CONF_PASSWORD)
130 if username and password:
131 self.client = SoundsClient(
132 session=self.mass.http_session,
133 logger=self.logger,
134 timezone=LOCAL_TIMEZONE,
135 username=str(username),
136 password=str(password),
137 )
138 try:
139 await self.client.login()
140 self.logged_in = True
141 except LoginFailedError as e:
142 raise LoginFailed(e)
143
144 else:
145 self.client = SoundsClient(
146 session=self.mass.http_session,
147 logger=self.logger,
148 timezone=LOCAL_TIMEZONE,
149 )
150 self.logged_in = False
151
152 self.show_local_stations: bool = bool(
153 self.config.get_value(_Constants.CONF_SHOW_LOCAL, False)
154 )
155 self.stream_format: _StreamTypes = (
156 _Constants.DASH
157 if self.config.get_value(_Constants.CONF_STREAM_FORMAT) == _Constants.DASH
158 else _Constants.HLS
159 )
160 self.adaptor = Adaptor(self)
161
162 async def loaded_in_mass(self) -> None:
163 """Do post-loaded actions."""
164 if not self.menu or (
165 isinstance(self.menu, Menu) and self.menu.sub_items and len(self.menu.sub_items) == 0
166 ):
167 await self._fetch_menu()
168
169 @property
170 def is_streaming_provider(self) -> bool:
171 """Return True as the provider is a streaming provider."""
172 return True
173
174 async def get_recommendations(self) -> list[RecommendationFolder]:
175 """Get this provider's available recommendation rows, without items."""
176 if not self.logged_in:
177 return []
178 return await self._recommendation_rows_from_payload()
179
180 async def get_recommendation_items(
181 self, item_id: str
182 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
183 """
184 Get the items for a single recommendation row.
185
186 :param item_id: The item_id of the row, as returned by get_recommendations.
187 """
188 if not self.logged_in:
189 return UniqueList()
190 return await self._recommendation_items_from_payload(item_id)
191
192 def _get_provider_mapping(self, item_id: str) -> ProviderMapping:
193 return ProviderMapping(
194 item_id=item_id,
195 provider_domain=self.domain,
196 provider_instance=self.instance_id,
197 )
198
199 def _stream_error(self, item_id: str, media_type: MediaType) -> MusicAssistantError:
200 return MusicAssistantError(f"Couldn't get stream details for {item_id} ({media_type})")
201
202 async def _fetch_menu(self) -> None:
203 self.logger.debug("No cached menu, fetching from API")
204 self.menu = await self.client.get_menu(recommendations=MenuRecommendationOptions.EXCLUDE)
205
206 @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
207 async def get_track(self, prov_track_id: str) -> Track:
208 """Get full track details by id."""
209 episode_info = await self.client.streaming.get_by_pid(
210 pid=prov_track_id, stream_format=self.stream_format
211 )
212 track = await self.adaptor.new_object(episode_info, force_type=Track)
213 if not isinstance(track, Track):
214 raise MusicAssistantError(f"Incorrect track returned for {prov_track_id}")
215 return track
216
217 @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
218 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
219 # If we are requesting a previously-aired radio show, we lose access to the
220 # schedule time. The best we can find out from the API is original release
221 # date, so the stream title loses access to the air date
222 """Get full podcast episode details by id."""
223 self.logger.debug(f"Getting podcast episode for {prov_episode_id}")
224 episode = await self.client.streaming.get_podcast_episode(prov_episode_id)
225 ma_episode = await self.adaptor.new_object(episode, force_type=PodcastEpisode)
226 if not ma_episode:
227 raise MusicAssistantError(f"Podcast episode {prov_episode_id} not found")
228 if not isinstance(ma_episode, PodcastEpisode):
229 raise MusicAssistantError(f"Incorrect format for podcast episode {prov_episode_id}")
230 ma_episode.name = (
231 episode.network.short_title
232 if episode.network and episode.network.short_title
233 else "Unknown"
234 )
235 return ma_episode
236
237 @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
238 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
239 """Get full podcast details by id."""
240 self.logger.debug(f"Getting podcast for {prov_podcast_id}")
241 podcast = await self.client.streaming.get_podcast(pid=prov_podcast_id)
242 ma_podcast = await self.adaptor.new_object(source_obj=podcast, force_type=Podcast)
243
244 if isinstance(ma_podcast, Podcast):
245 return ma_podcast
246 raise MusicAssistantError("Incorrect format for podcast")
247
248 async def get_podcast_episodes(
249 self,
250 prov_podcast_id: str,
251 ) -> AsyncGenerator[PodcastEpisode]:
252 """Get all PodcastEpisodes for given podcast id."""
253 podcast_episodes = await self.client.streaming.get_podcast_episodes(prov_podcast_id)
254
255 if podcast_episodes:
256 for episode in podcast_episodes:
257 this_episode = await self.adaptor.new_object(
258 source_obj=episode, force_type=PodcastEpisode
259 )
260 if this_episode and isinstance(this_episode, PodcastEpisode):
261 yield this_episode
262
263 @use_cache(expiration=_Constants.SHORT_EXPIRATION)
264 async def get_radio(self, prov_radio_id: str) -> Radio:
265 """Get full radio details by id."""
266 self.logger.debug(f"Getting radio for {prov_radio_id}")
267 station = await self.client.stations.get_station(prov_radio_id, include_stream=True)
268 if station:
269 ma_radio = await self.adaptor.new_object(station, force_type=Radio)
270 if ma_radio and isinstance(ma_radio, Radio):
271 return ma_radio
272 else:
273 raise MediaNotFoundError(f"No station found: {prov_radio_id}")
274
275 self.logger.debug(f"{station} {ma_radio} {type(ma_radio)}")
276 raise MediaNotFoundError("No valid radio stream found")
277
278 async def _catch_up_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
279 """Get stream details for catch-up content."""
280 episode = await self.client.streaming.get_by_pid(
281 item_id,
282 include_stream=True,
283 stream_format=self.stream_format,
284 )
285
286 stream_details = await self.adaptor.new_streamable_object(episode)
287
288 if not stream_details or not isinstance(episode, PlayableItem):
289 raise self._stream_error(item_id, media_type)
290
291 stream_details.data = {"vpid": episode.id, "pid": episode.pid}
292 stream_details.stream_metadata_update_callback = self._update_on_demand_stream_metadata
293 stream_details.stream_metadata_update_interval = _Constants.NOW_PLAYING_REFRESH_TIME
294 return stream_details
295
296 async def _get_station_stream_details(self, item_id: str) -> StreamDetails:
297 """Fetch stream details for a live station."""
298 station = await self.client.stations.get_station(
299 item_id,
300 include_stream=True,
301 stream_format=self.stream_format,
302 )
303
304 if not station:
305 raise MusicAssistantError(f"Couldn't get stream details for station {item_id}")
306
307 if not station.stream:
308 raise MusicAssistantError(f"No stream found for {item_id}")
309
310 stream_details = await self.adaptor.new_streamable_object(station)
311
312 if not stream_details:
313 raise self._stream_error(item_id, MediaType.RADIO)
314
315 stream_details.stream_metadata_update_callback = self._update_live_stream_metadata
316 stream_details.stream_metadata_update_interval = _Constants.NOW_PLAYING_REFRESH_TIME
317
318 return stream_details
319
320 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
321 """Get streamdetails for a track/radio."""
322 self.logger.debug(f"Getting stream details for {item_id} ({media_type})")
323 if media_type in [MediaType.PODCAST_EPISODE, MediaType.TRACK]:
324 return await self._catch_up_stream_details(item_id, media_type)
325 return await self._get_station_stream_details(item_id)
326
327 async def _get_programme_segments(self, vpid: str) -> list[Segment] | None:
328 """Get on demand segments from cache or API."""
329 cached = await self.mass.cache.get(
330 provider=self.domain, key=f"programme_segments_{vpid}", default=False
331 )
332 if cached is False:
333 self.logger.debug(f"No cache for programme segments for {vpid}")
334 segments = await self.client.streaming.get_show_segments(vpid)
335 if isinstance(segments, list):
336 await self.mass.cache.set(
337 provider=self.domain,
338 key=f"programme_segments_{vpid}",
339 data=[Segment.to_dict(s) for s in segments],
340 )
341 return segments
342 return None
343 if isinstance(cached, list):
344 self.logger.debug(f"Cache hit for programme segments for {vpid}")
345 return [Segment(**item) for item in cached]
346 return None
347
348 async def _update_on_demand_stream_metadata(
349 self, stream_details: StreamDetails, elapsed_time: int
350 ) -> None:
351 """
352 Get the currently playing segment (song) for on-demand episodes.
353
354 Called by the callback function in StreamDetails.
355 """
356 self.logger.debug("Updating on-demand stream metadata")
357
358 if not stream_details or not stream_details.stream_metadata:
359 return
360
361 vpid = stream_details.data.get("vpid")
362 if not vpid:
363 self.logger.warning("No VPID found")
364 return
365
366 segments = await self._get_programme_segments(vpid)
367 if not segments:
368 return
369
370 segment = _find_segment(segments, elapsed_time)
371
372 if segment:
373 metadata = _segment_to_metadata(segment)
374 if metadata:
375 stream_details.stream_metadata = metadata
376 # As of June 2026, the API currently doesn't return images from this endpoint
377 # We fill in missing images with the Spotify API, if any are still blank
378 # then use the MA helpers here
379 if not stream_details.stream_metadata.image_url:
380 try:
381 async with asyncio.timeout(_Constants.ARTWORK_TIMEOUT):
382 await self.mass.metadata.update_radio_stream_artwork(stream_details)
383 except TimeoutError:
384 self.logger.debug("Timeout while waiting for artwork")
385 return
386
387 # Nothing playing; show episode metadata
388 episode_info = await self._catch_up_stream_details(
389 item_id=stream_details.data.get("pid"),
390 media_type=stream_details.media_type,
391 )
392
393 if episode_info.stream_title:
394 stream_details.stream_title = episode_info.stream_title
395
396 if episode_info.stream_metadata:
397 stream_details.stream_metadata = episode_info.stream_metadata
398
399 async def _update_live_stream_metadata(
400 self, stream_details: StreamDetails, elapsed_time: int
401 ) -> None:
402 """Get the currently playing song for live radio streams."""
403 self.logger.debug("Updating live stream metadata")
404 if not stream_details or not stream_details.stream_metadata:
405 return
406
407 station_id = stream_details.item_id
408 if not station_id:
409 return
410
411 now_playing = await self.client.schedules.currently_playing_song(station_id)
412 if now_playing:
413 self.logger.debug(f"Now playing for {station_id}: {now_playing}")
414 stream_details.stream_metadata = _segment_to_metadata(now_playing)
415 else:
416 self.logger.debug(f"No song playing on {station_id}, fetching station info")
417 station = await self.client.stations.get_station(station_id)
418 if station:
419 stream_details.stream_metadata = await self._station_programme_display(
420 station=station
421 )
422
423 @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
424 async def _vod_programme_display(self, pid: str) -> StreamMetadata | None:
425 episode = await self.client.streaming.get_by_pid(pid=pid, stream_format=self.stream_format)
426
427 if isinstance(episode, (SoundsPodcastEpisode, RadioShow)) and episode.titles:
428 return StreamMetadata(title=episode.titles.get("secondary", ""))
429
430 return None
431
432 @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
433 async def _station_programme_display(self, station: LiveStation) -> StreamMetadata | None:
434 if station and station.titles:
435 title = f"{station.titles.get('secondary')} • {station.titles.get('primary')}"
436 return StreamMetadata(title=title, artist=None, image_url=station.image_url)
437 return None
438
439 @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
440 async def _station_list(self, include_local: bool = False) -> list[Radio]:
441 """Get list of stations as Radios."""
442 radio_list: list[Radio] = []
443 for station in await self.client.stations.get_stations(include_local=include_local):
444 if station and station.item_id:
445 station_info = await self._station_programme_display(station=station)
446 description = station_info.title if station_info else None
447 radio_list.append(
448 Radio(
449 item_id=station.item_id,
450 name=(
451 station.network.short_title
452 if station.network and station.network.short_title
453 else "Unknown station"
454 ),
455 provider=self.domain,
456 metadata=MediaItemMetadata(
457 description=description,
458 images=(
459 UniqueList(
460 [
461 MediaItemImage(
462 type=ImageType.THUMB,
463 provider=self.domain,
464 path=station.network.logo_url,
465 remotely_accessible=True,
466 ),
467 ]
468 )
469 if station.network and station.network.logo_url
470 else None
471 ),
472 ),
473 provider_mappings={
474 ProviderMapping(
475 item_id=station.item_id,
476 provider_domain=self.domain,
477 provider_instance=self.instance_id,
478 )
479 },
480 )
481 )
482 return radio_list
483
484 async def _get_menu(
485 self, path_parts: list[str] | None = None
486 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
487 if not self.menu:
488 await self._fetch_menu()
489 if not self.menu or not self.menu.sub_items:
490 raise MusicAssistantError("Menu API response is empty or invalid")
491 menu_items = []
492 for item in self.menu.sub_items:
493 new_item = await self._render_browse_item(item, path_parts)
494 if isinstance(new_item, (MediaItemType | ItemMapping | BrowseFolder)):
495 menu_items.append(new_item)
496
497 return menu_items
498
499 async def _render_browse_item(
500 self,
501 item: SoundsTypes,
502 path_parts: list[str] | None = None,
503 ) -> BrowseFolder | Track | Podcast | PodcastEpisode | RecommendationFolder | Radio | None:
504 new_item = await self.adaptor.new_object(item, path_parts=path_parts)
505 if isinstance(
506 new_item,
507 (BrowseFolder | Track | Podcast | PodcastEpisode | RecommendationFolder | Radio),
508 ):
509 return new_item
510 return None
511
512 async def _get_subpath_menu(
513 self, path_parts: list[str]
514 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
515 item_list: list[MediaItemType | ItemMapping | BrowseFolder] = []
516 if not self.menu:
517 return item_list
518 sub_menu = self.menu.get(path_parts[0])
519
520 if isinstance(sub_menu, Container):
521 for part in path_parts[1:]:
522 if not isinstance(sub_menu, MenuItem):
523 break
524 sub_menu = sub_menu.get(part)
525 if sub_menu is None:
526 break
527 else:
528 if isinstance(sub_menu, MenuItem) and sub_menu.sub_items is not None:
529 for item in sub_menu.sub_items:
530 if new_item := await self._render_browse_item(
531 item, path_parts=[f"{self.domain}:/", *path_parts]
532 ):
533 item_list.append(new_item)
534 # Playlists are returned empty in the main menu API
535 # TODO: probably need a better way of handling this
536 elif isinstance(sub_menu, Playlist):
537 playlist_items = await self.client.streaming.get_playlist_contents(
538 pid=sub_menu.item_id
539 )
540 if playlist_items:
541 rendered_items = [
542 await self._render_browse_item(playlist_item)
543 for playlist_item in playlist_items
544 if playlist_item is not None
545 ]
546 item_list += [item for item in rendered_items if item is not None]
547 else:
548 self.logger.warning(f"Sub menu not a container: {sub_menu}")
549 return item_list
550
551 async def _get_station_schedule_menu(
552 self,
553 station_id: str,
554 path_parts: list[str],
555 date: str,
556 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
557 """Lookup a date schedule for a station."""
558 self.logger.debug(f"Getting schedule for {station_id} for {date}")
559 schedule = await self.client.schedules.get_schedule(
560 station_id=station_id,
561 date=date,
562 )
563 items = []
564 if schedule and schedule.sub_items:
565 for folder in schedule.sub_items:
566 new_folder = await self._render_browse_item(folder, path_parts=path_parts)
567 if new_folder:
568 items.append(new_folder)
569 return items
570
571 @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
572 async def _get_category(
573 self, category_name: str
574 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
575 category = await self.client.streaming.get_category(category=category_name)
576
577 if category is not None and category.sub_items:
578 return [
579 obj
580 for obj in [await self._render_browse_item(item) for item in category.sub_items]
581 if obj is not None
582 ]
583 return []
584
585 @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
586 async def _get_collection(
587 self, pid: str
588 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
589 collection = await self.client.streaming.get_collection(pid=pid)
590 if collection and collection.sub_items:
591 return [
592 obj
593 for obj in [
594 await self._render_browse_item(item) for item in collection.sub_items if item
595 ]
596 if obj
597 ]
598 return []
599
600 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
601 """
602 Browse this provider's items.
603
604 :param path: The path to browse, (e.g. provider_id://artists).
605 """
606 self.logger.debug(f"Browsing path: {path}")
607 if not path.startswith(f"{self.domain}://"):
608 raise MusicAssistantError(f"Invalid path for {self.domain} provider: {path}")
609 path_parts = path.split("://", 1)[1].split("/")
610 self.logger.debug(f"Path parts: {path_parts}")
611
612 sub_path = path_parts[0] if path_parts else ""
613 sub_sub_path = path_parts[1] if len(path_parts) > 1 else ""
614 sub_sub_sub_path = path_parts[2] if len(path_parts) > 2 else ""
615 path_parts = [
616 f"{self.domain}:/",
617 *[part for part in path_parts if len(part) > 0],
618 ]
619
620 # A large part of the menu content is pre-loaded into self.menu
621 # These are the exceptions, so get the extra content
622 if sub_path == "":
623 return await self._get_menu()
624 # Categories and collections aren't in the API menus
625 if sub_path == "categories" and sub_sub_path:
626 return await self._get_category(sub_sub_path)
627 if sub_path == "collections" and sub_sub_path:
628 return await self._get_collection(sub_sub_path)
629 # The main menu fetch returns up to the schedule date folders, but no contents
630 # so as not to show out of date information
631 if sub_path == "stations" and sub_sub_path and sub_sub_sub_path:
632 return await self._get_station_schedule_menu(
633 path_parts=path_parts,
634 station_id=sub_sub_path,
635 date=sub_sub_sub_path,
636 )
637 # If no special cases, pass the rest of the path to iterate through
638 return await self._get_subpath_menu(path_parts[1:])
639
640 async def search(
641 self, search_query: str, media_types: list[MediaType] | None, limit: int = 5
642 ) -> SearchResults:
643 """Perform search for BBC Sounds stations."""
644 results = SearchResults()
645 search_result = await self.client.streaming.search(search_query)
646 self.logger.debug(search_result)
647 if media_types is None or MediaType.RADIO in media_types:
648 radios = [await self.adaptor.new_object(radio) for radio in search_result.stations]
649 results.radio = [radio for radio in radios if isinstance(radio, Radio)]
650 if (
651 media_types is None
652 or MediaType.TRACK in media_types
653 or MediaType.PODCAST_EPISODE in media_types
654 ):
655 episodes = [await self.adaptor.new_object(track) for track in search_result.episodes]
656 results.tracks = [track for track in episodes if type(track) is Track]
657
658 if media_types is None or MediaType.PODCAST in media_types:
659 podcasts = [await self.adaptor.new_object(show) for show in search_result.shows]
660 results.podcasts = [podcast for podcast in podcasts if isinstance(podcast, Podcast)]
661
662 return results
663
664 async def on_played(
665 self,
666 media_type: MediaType,
667 prov_item_id: str,
668 fully_played: bool,
669 position: int,
670 media_item: MediaItemType,
671 is_playing: bool = False,
672 ) -> None:
673 """Handle callback when a (playable) media item has been played."""
674 if self.logged_in:
675 if media_type != MediaType.RADIO:
676 # Handle Sounds API play status updates
677 action = None
678
679 if is_playing:
680 action = PlayStatus.STARTED if position < 30 else PlayStatus.HEARTBEAT
681 elif fully_played:
682 action = PlayStatus.ENDED
683 else:
684 action = PlayStatus.PAUSED
685
686 if action:
687 try:
688 success = await self.client.streaming.update_play_status(
689 pid=media_item.item_id, elapsed_time=position, action=action
690 )
691 self.logger.debug(f"Updated play status: {success}")
692 except exceptions.APIResponseError as err:
693 self.logger.error(f"Error updating play status: {err}")
694
695 async def _fetch_recommendation_payload(self) -> list[RecommendationFolder]:
696 """Fetch the experience-menu recommendation folders, with items."""
697 self.logger.debug("Getting recommendations from API")
698 folders: list[RecommendationFolder] = []
699 recommendations = await self.client.personal.get_experience_menu(
700 recommendations=MenuRecommendationOptions.ONLY
701 )
702 if recommendations.sub_items:
703 for recommendation in recommendations.sub_items:
704 # recommendation is a RecommendedMenuItem
705 folder = await self.adaptor.new_object(
706 recommendation, force_type=RecommendationFolder
707 )
708 if isinstance(folder, RecommendationFolder):
709 folders.append(folder)
710 return folders
711