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