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