Add bbc sounds provider (#2567)
authorKieran Hogg <kieran.hogg@gmail.com>
Tue, 25 Nov 2025 17:45:29 +0000 (17:45 +0000)
committerGitHub <noreply@github.com>
Tue, 25 Nov 2025 17:45:29 +0000 (18:45 +0100)
* Initial commit of BBC Sounds provider

* Remove unused SUPPORTED_FEATURES

* Remove commented code; tidy stream and provider variables; accept ruff formatting

* Add search

* Initial commit of BBC Sounds provider

* Remove unused SUPPORTED_FEATURES

* Remove commented code; tidy stream and provider variables; accept ruff formatting

* Add search

* Initial commit of BBC Sounds provider

* First version of (almost) the full Sounds functionality

* Remove accidental leading _ in get_podcast_episode()

* Use network logo and not programme image for schedule listing

* Remove commented out code

* Tidy up object titles and update to latest auntie-sounds API

* Remove unused supported features

* Add feature flag for blank image check

* Cancel now playing task and tidy up

* Update icons

* Return SUPPORTED_FEATURES with instance in setup()

* Remove the enable UK content toggle

* Remove boiler comments and tweak wording

* Check image_url is set after library change

* Update library version

* Fix typo in comment

Co-authored-by: OzGav <gavnosp@hotmail.com>
* Remove the check to provide the seekable stream version.

It's not yet used and has the potential to affect international users, so remove it be safe.

* Move fetching the menu to prevent it not being available if accessed by another route

* Add an advanced option to choose a stream format

* Update dependency version

* Remove unnecessary config options

* Remove a couple of incorrectly-set StreamDetails variables on Radio streams

* Tidy up after removing config options

* Add some sensible caching

* Increase cache expiry

* Add cache to get_podcast()

* Disable 'now playing' until supported in core

---------

Co-authored-by: OzGav <gavnosp@hotmail.com>
music_assistant/providers/bbc_sounds/__init__.py [new file with mode: 0644]
music_assistant/providers/bbc_sounds/adaptor.py [new file with mode: 0644]
music_assistant/providers/bbc_sounds/icon.svg [new file with mode: 0644]
music_assistant/providers/bbc_sounds/manifest.json [new file with mode: 0644]
requirements_all.txt

diff --git a/music_assistant/providers/bbc_sounds/__init__.py b/music_assistant/providers/bbc_sounds/__init__.py
new file mode 100644 (file)
index 0000000..2f91511
--- /dev/null
@@ -0,0 +1,802 @@
+"""
+BBC Sounds music provider support for MusicAssistant.
+
+TODO implement seeking of live stream
+TODO watch for settings change
+TODO add podcast menu to non-UK menu
+FIXME skipping in non-live radio shows restarts the stream but keeps the seek time
+"""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncGenerator
+from datetime import timedelta
+from typing import TYPE_CHECKING, Literal
+
+from music_assistant_models.config_entries import (
+    ConfigEntry,
+    ConfigValueOption,
+    ConfigValueType,
+    ProviderConfig,
+)
+from music_assistant_models.enums import ConfigEntryType, ImageType, MediaType, ProviderFeature
+from music_assistant_models.errors import LoginFailed, MusicAssistantError
+from music_assistant_models.media_items import (
+    BrowseFolder,
+    ItemMapping,
+    MediaItemImage,
+    MediaItemMetadata,
+    MediaItemType,
+    Podcast,
+    PodcastEpisode,
+    ProviderMapping,
+    Radio,
+    RecommendationFolder,
+    SearchResults,
+    Track,
+)
+from music_assistant_models.unique_list import UniqueList
+
+import music_assistant.helpers.datetime as dt
+from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
+from music_assistant.controllers.cache import use_cache
+from music_assistant.helpers.datetime import LOCAL_TIMEZONE
+from music_assistant.models.music_provider import MusicProvider
+from music_assistant.providers.bbc_sounds.adaptor import Adaptor
+
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+
+    from music_assistant_models.provider import ProviderManifest
+    from music_assistant_models.streamdetails import StreamDetails
+    from sounds.models import SoundsTypes
+
+    from music_assistant.mass import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+from sounds import (
+    Container,
+    LiveStation,
+    Menu,
+    MenuRecommendationOptions,
+    PlayStatus,
+    SoundsClient,
+    exceptions,
+)
+
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.RECOMMENDATIONS,
+    ProviderFeature.SEARCH,
+}
+
+FEATURES = {"now_playing": False, "catchup_segments": False, "check_blank_image": False}
+
+type _StreamTypes = Literal["hls", "dash"]
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Create new provider instance."""
+    instance = BBCSoundsProvider(mass, manifest, config, SUPPORTED_FEATURES)
+    await instance.handle_async_init()
+    return instance
+
+
+async def get_config_entries(
+    mass: MusicAssistant,
+    instance_id: str | None = None,
+    action: str | None = None,
+    values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    instance_id: id of an existing provider instance (None if new instance setup).
+    action: [optional] action key called from config entries UI.
+    values: the (intermediate) raw values for config entries sent with the action.
+    """
+    # ruff: noqa: ARG001
+
+    return (
+        ConfigEntry(
+            key=_Constants.CONF_INTRO,
+            type=ConfigEntryType.LABEL,
+            label="A BBC Sounds account is optional, but some UK-only content may not work without"
+            " it",
+        ),
+        ConfigEntry(
+            key=CONF_USERNAME,
+            type=ConfigEntryType.STRING,
+            label="Email or username",
+            required=False,
+        ),
+        ConfigEntry(
+            key=CONF_PASSWORD,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Password",
+            required=False,
+        ),
+        ConfigEntry(
+            key=_Constants.CONF_SHOW_LOCAL,
+            category="advanced",
+            type=ConfigEntryType.BOOLEAN,
+            label="Show local radio stations?",
+            default_value=False,
+        ),
+        ConfigEntry(
+            key=_Constants.CONF_STREAM_FORMAT,
+            category="advanced",
+            label="Preferred stream format",
+            type=ConfigEntryType.STRING,
+            options=[
+                ConfigValueOption(
+                    "HLS",
+                    _Constants.CONF_STREAM_FORMAT_HLS,
+                ),
+                ConfigValueOption(
+                    "MPEG-DASH",
+                    _Constants.CONF_STREAM_FORMAT_DASH,
+                ),
+            ],
+            default_value=_Constants.CONF_STREAM_FORMAT_HLS,
+        ),
+    )
+
+
+class _Constants:
+    # This is the image id that is shown when there's no track image
+    BLANK_IMAGE_NAME: str = "p0bqcdzf"
+    DEFAULT_IMAGE_SIZE = 1280
+    TRACK_DURATION_THRESHOLD: int = 300  # 5 minutes
+    NOW_PLAYING_REFRESH_TIME: int = 5
+    HLS: Literal["hls"] = "hls"
+    DASH: Literal["dash"] = "dash"
+    CONF_SHOW_LOCAL: str = "show_local"
+    CONF_INTRO: str = "intro"
+    CONF_STREAM_FORMAT: str = "stream_format"
+    CONF_STREAM_FORMAT_HLS: str = HLS
+    CONF_STREAM_FORMAT_DASH: str = DASH
+    DEFAULT_EXPIRATION = 60 * 60 * 24 * 30  # 30 days
+    SHORT_EXPIRATION = 60 * 60 * 3  # 3 hours
+
+
+class BBCSoundsProvider(MusicProvider):
+    """A MusicProvider class to interact with the BBC Sounds API via auntie-sounds."""
+
+    client: SoundsClient
+    menu: Menu | None = None
+    current_task: asyncio.Task[None] | None = None
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        self.client = SoundsClient(
+            session=self.mass.http_session,
+            logger=self.logger,
+            timezone=LOCAL_TIMEZONE,
+        )
+
+        self.show_local_stations: bool = bool(
+            self.config.get_value(_Constants.CONF_SHOW_LOCAL, False)
+        )
+        self.stream_format: _StreamTypes = (
+            _Constants.DASH
+            if self.config.get_value(_Constants.CONF_STREAM_FORMAT) == _Constants.DASH
+            else _Constants.HLS
+        )
+        self.adaptor = Adaptor(self)
+
+        # If we have an account, authenticate. Testing shows all features work without auth
+        # but BBC will be disabling BBC Sounds from outside the UK at some point
+        if self.config.get_value(CONF_USERNAME) and self.config.get_value(CONF_PASSWORD):
+            if self.client.auth.is_logged_in:
+                # Check if we need to reauth
+                try:
+                    await self.client.personal.get_experience_menu()
+                    return
+                except (exceptions.UnauthorisedError, exceptions.APIResponseError):
+                    await self.client.auth.renew_session()
+
+            try:
+                await self.client.auth.authenticate(
+                    username=str(self.config.get_value(CONF_USERNAME)),
+                    password=str(self.config.get_value(CONF_PASSWORD)),
+                )
+            except exceptions.LoginFailedError as e:
+                raise LoginFailed(e)
+
+    async def loaded_in_mass(self) -> None:
+        """Do post-loaded actions."""
+        if not self.menu or (
+            isinstance(self.menu, Menu) and self.menu.sub_items and len(self.menu.sub_items) == 0
+        ):
+            await self._fetch_menu()
+
+    def _get_provider_mapping(self, item_id: str) -> ProviderMapping:
+        return ProviderMapping(
+            item_id=item_id,
+            provider_domain=self.domain,
+            provider_instance=self.instance_id,
+        )
+
+    async def _fetch_menu(self) -> None:
+        self.logger.debug("No cached menu, fetching from API")
+        self.menu = await self.client.personal.get_experience_menu(
+            recommendations=MenuRecommendationOptions.EXCLUDE
+        )
+
+    def _stream_error(self, item_id: str, media_type: MediaType) -> MusicAssistantError:
+        return MusicAssistantError(f"Couldn't get stream details for {item_id} ({media_type})")
+
+    @property
+    def is_streaming_provider(self) -> bool:
+        """Return True as the provider is a streaming provider."""
+        return True
+
+    @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
+    async def get_track(self, prov_track_id: str) -> Track:
+        """Get full track details by id."""
+        episode_info = await self.client.streaming.get_by_pid(
+            pid=prov_track_id, stream_format=self.stream_format
+        )
+        track = await self.adaptor.new_object(episode_info, force_type=Track)
+        if not isinstance(track, Track):
+            raise MusicAssistantError(f"Incorrect track returned for {prov_track_id}")
+        return track
+
+    @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
+    async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
+        # If we are requesting a previously-aired radio show, we lose access to the
+        # schedule time. The best we can find out from the API is original release
+        # date, so the stream title loses access to the air date
+        """Get full podcast episode details by id."""
+        self.logger.debug(f"Getting podcast episode for {prov_episode_id}")
+        episode = await self.client.streaming.get_podcast_episode(prov_episode_id)
+        ma_episode = await self.adaptor.new_object(episode, force_type=PodcastEpisode)
+        if not isinstance(ma_episode, PodcastEpisode):
+            raise MusicAssistantError(f"Incorrect format for podcast episode {prov_episode_id}")
+        return ma_episode
+
+    async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
+        """Get streamdetails for a track/radio."""
+        self.logger.debug(f"Getting stream details for {item_id} ({media_type})")
+        if media_type == MediaType.PODCAST_EPISODE:
+            episode_info = await self.client.streaming.get_by_pid(
+                item_id, include_stream=True, stream_format=self.stream_format
+            )
+            stream_details = await self.adaptor.new_streamable_object(episode_info)
+            if not stream_details:
+                raise self._stream_error(item_id, media_type)
+
+            # Hide behind a feature flag until it can be better tested
+            if episode_info and FEATURES["catchup_segments"]:
+                # .item_id is the VPID
+                self.current_task = self.mass.create_task(
+                    self._check_for_segments(item_id, stream_details)
+                )
+            return stream_details
+        elif media_type is MediaType.TRACK:
+            track = await self.client.streaming.get_by_pid(
+                item_id, include_stream=True, stream_format=self.stream_format
+            )
+            stream_details = await self.adaptor.new_streamable_object(track)
+            if not stream_details:
+                raise self._stream_error(item_id, media_type)
+            self.current_task = self.mass.create_task(
+                self._check_for_segments(item_id, stream_details)
+            )
+            return stream_details
+        else:
+            self.logger.debug(f"Getting stream details for station {item_id}")
+            station = await self.client.stations.get_station(
+                item_id, include_stream=True, stream_format=self.stream_format
+            )
+            if not station:
+                raise MusicAssistantError(f"Couldn't get stream details for station {item_id}")
+
+            self.logger.debug(f"Found station: {station}")
+            if not station.stream:
+                raise MusicAssistantError(f"No stream found for {item_id}")
+
+            stream_details = await self.adaptor.new_streamable_object(station)
+
+            if not stream_details:
+                raise self._stream_error(item_id, media_type)
+
+            # Start a background task to keep these details updated
+            if FEATURES["now_playing"]:
+                self.current_task = self.mass.create_task(
+                    self._watch_stream_details(stream_details)
+                )
+            return stream_details
+
+    async def _check_for_segments(self, vpid: str, stream_details: StreamDetails) -> None:
+        # seeking past the current segment needs fixing
+        segments = await self.client.streaming.get_show_segments(vpid)
+        offset = stream_details.seek_position + (stream_details.seconds_streamed or 0)
+        if segments:
+            seconds = 0 + offset
+            segments_iter = iter(segments)
+            segment = next(segments_iter)
+            if seconds > 0:
+                # Skip to the correct segment
+                prev = None
+                while seconds > segment.offset["start"]:
+                    self.logger.info("Advancing to next segment")
+                    prev = segment
+                    segment = next(segments_iter)
+                self.logger.warning("Starting with first segment")
+                if prev and seconds > prev.offset["start"] and seconds < prev.offset["end"]:
+                    if stream_details.stream_metadata:
+                        stream_details.stream_metadata.artist = prev.titles["primary"]
+                        stream_details.stream_metadata.title = prev.titles["secondary"]
+                        if prev.image_url:
+                            stream_details.stream_metadata.image_url = prev.image_url
+            while True:
+                if seconds == segment.offset["start"] and stream_details.stream_metadata:
+                    self.logger.warning("Updating segment")
+                    stream_details.stream_metadata.artist = segment.titles["primary"]
+                    stream_details.stream_metadata.title = segment.titles["secondary"]
+                    if segment.image_url:
+                        stream_details.stream_metadata.image_url = segment.image_url
+                    segment = next(segments_iter)
+                await asyncio.sleep(1)
+                seconds += 1
+        else:
+            self.logger.warning("No segments found")
+
+    async def _watch_stream_details(self, stream_details: StreamDetails) -> None:
+        station_id = stream_details.data["station"]
+
+        while True:
+            if not stream_details.stream_metadata:
+                await asyncio.sleep(_Constants.NOW_PLAYING_REFRESH_TIME)
+                continue
+
+            now_playing = await self.client.schedules.currently_playing_song(
+                station_id, image_size=_Constants.DEFAULT_IMAGE_SIZE
+            )
+
+            if now_playing and stream_details.stream_metadata:
+                self.logger.debug(f"Now playing for {station_id}: {now_playing}")
+
+                # removed check temporarily as images not working
+                if not FEATURES["check_blank_image"] or (
+                    now_playing.image_url
+                    and _Constants.BLANK_IMAGE_NAME not in now_playing.image_url
+                ):
+                    stream_details.stream_metadata.image_url = now_playing.image_url
+                song = now_playing.titles["secondary"]
+                artist = now_playing.titles["primary"]
+                stream_details.stream_metadata.title = song
+                stream_details.stream_metadata.artist = artist
+            elif stream_details.stream_metadata:
+                station = await self.client.stations.get_station(station_id=station_id)
+                if station:
+                    self.logger.debug(f"Station details: {station}")
+                    display = self._station_programme_display(station)
+                    if display:
+                        stream_details.stream_metadata.title = display
+                        stream_details.stream_metadata.artist = None
+                        stream_details.stream_metadata.image_url = station.image_url
+            await asyncio.sleep(_Constants.NOW_PLAYING_REFRESH_TIME)
+
+    def _station_programme_display(self, station: LiveStation) -> str | None:
+        if station and station.titles:
+            return f"{station.titles.get('secondary')} • {station.titles.get('primary')}"
+        return None
+
+    async def _station_list(self, include_local: bool = False) -> list[Radio]:
+        return [
+            Radio(
+                item_id=station.item_id,
+                name=(
+                    station.network.short_title
+                    if station.network and station.network.short_title
+                    else "Unknown station"
+                ),
+                provider=self.domain,
+                metadata=MediaItemMetadata(
+                    description=self._station_programme_display(station=station),
+                    images=(
+                        UniqueList(
+                            [
+                                MediaItemImage(
+                                    type=ImageType.THUMB,
+                                    provider=self.domain,
+                                    path=station.network.logo_url,
+                                    remotely_accessible=True,
+                                ),
+                            ]
+                        )
+                        if station.network and station.network.logo_url
+                        else None
+                    ),
+                ),
+                provider_mappings={
+                    ProviderMapping(
+                        item_id=station.item_id,
+                        provider_domain=self.domain,
+                        provider_instance=self.instance_id,
+                    )
+                },
+            )
+            for station in await self.client.stations.get_stations(include_local=include_local)
+            if station and station.item_id
+        ]
+
+    async def _get_category(
+        self, category_name: str
+    ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        category = await self.client.streaming.get_category(category=category_name)
+
+        if category is not None and category.sub_items:
+            return [
+                obj
+                for obj in [await self._render_browse_item(item) for item in category.sub_items]
+                if obj is not None
+            ]
+        else:
+            return []
+
+    async def _get_collection(
+        self, pid: str
+    ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        collection = await self.client.streaming.get_collection(pid=pid)
+        if collection and collection.sub_items:
+            return [
+                obj
+                for obj in [
+                    await self._render_browse_item(item) for item in collection.sub_items if item
+                ]
+                if obj
+            ]
+        else:
+            return []
+
+    async def _get_menu(
+        self, path_parts: list[str] | None = None
+    ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        if self.client.auth.is_logged_in and await self.client.auth.is_uk_listener:
+            return await self._get_full_menu(path_parts=path_parts)
+        else:
+            return await self._get_slim_menu(path_parts=path_parts)
+
+    async def _get_full_menu(
+        self, path_parts: list[str] | None = None
+    ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        if not self.menu or not self.menu.sub_items:
+            raise MusicAssistantError("Menu API response is empty or invalid")
+        menu_items = []
+        for item in self.menu.sub_items:
+            new_item = await self._render_browse_item(item, path_parts)
+            if isinstance(new_item, (MediaItemType | ItemMapping | BrowseFolder)):
+                menu_items.append(new_item)
+
+        # The Sounds default menu doesn't include listings as they are linked elsewhere
+        menu_items.insert(
+            1,
+            BrowseFolder(
+                item_id="stations",
+                provider=self.domain,
+                name="Schedule and Programmes",
+                path=f"{self.domain}://stations",
+                image=MediaItemImage(
+                    path="https://cdn.jsdelivr.net/gh/kieranhogg/auntie-sounds@main/src/sounds/icons/solid/latest.png",
+                    remotely_accessible=True,
+                    provider=self.domain,
+                    type=ImageType.THUMB,
+                ),
+            ),
+        )
+        return menu_items
+
+    async def _get_slim_menu(
+        self, path_parts: list[str] | None
+    ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        return [
+            BrowseFolder(
+                item_id="listen_live",
+                provider=self.domain,
+                name="Listen Live",
+                path=f"{self.domain}://listen_live",
+                image=MediaItemImage(
+                    path="https://cdn.jsdelivr.net/gh/kieranhogg/auntie-sounds@main/src/sounds/icons/solid/listen_live.png",
+                    remotely_accessible=True,
+                    provider=self.domain,
+                    type=ImageType.THUMB,
+                ),
+            ),
+            BrowseFolder(
+                item_id="stations",
+                provider=self.domain,
+                name="Schedules and Programmes",
+                path=f"{self.domain}://stations",
+                image=MediaItemImage(
+                    path="https://cdn.jsdelivr.net/gh/kieranhogg/auntie-sounds@main/src/sounds/icons/solid/latest.png",
+                    remotely_accessible=True,
+                    provider=self.domain,
+                    type=ImageType.THUMB,
+                ),
+            ),
+        ]
+
+    async def _render_browse_item(
+        self,
+        item: SoundsTypes,
+        path_parts: list[str] | None = None,
+    ) -> BrowseFolder | Track | Podcast | PodcastEpisode | RecommendationFolder | Radio | None:
+        new_item = await self.adaptor.new_object(item, path_parts=path_parts)
+        if isinstance(
+            new_item,
+            (BrowseFolder | Track | Podcast | PodcastEpisode | RecommendationFolder | Radio),
+        ):
+            return new_item
+        else:
+            return None
+
+    async def _get_subpath_menu(
+        self, sub_path: str
+    ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        if not self.menu:
+            return []
+        sub_menu = self.menu.get(sub_path)
+        item_list = []
+
+        if sub_menu and isinstance(sub_menu, Container):
+            if sub_menu.sub_items:
+                # We have some sub-items, so let's show those
+                for item in sub_menu.sub_items:
+                    new_item = await self._render_browse_item(item)
+                    if new_item:
+                        item_list.append(new_item)
+            else:
+                new_item = await self._render_browse_item(sub_menu)
+                if new_item:
+                    item_list.append(new_item)
+
+        if sub_path == "listen_live":
+            for item in await self.client.stations.get_stations():
+                new_item = await self._render_browse_item(item)
+                if new_item:
+                    item_list.append(new_item)
+            # Check if we need to append local stations
+            if self.show_local_stations:
+                for item in await self.client.stations.get_local_stations():
+                    new_item = await self._render_browse_item(item)
+                    if new_item is not None:
+                        item_list.append(new_item)
+        return item_list
+
+    async def _get_station_schedule_menu(
+        self,
+        show_local: bool,
+        path_parts: list[str],
+        sub_sub_path: str,
+        sub_sub_sub_path: str,
+    ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        if sub_sub_sub_path:
+            # Lookup a date schedule
+            self.logger.debug(
+                await self.client.schedules.get_schedule(
+                    station_id=sub_sub_path,
+                    date=sub_sub_sub_path,
+                )
+            )
+            schedule = await self.client.schedules.get_schedule(
+                station_id=sub_sub_path,
+                date=sub_sub_sub_path,
+            )
+            items = []
+            if schedule and schedule.sub_items:
+                for folder in schedule.sub_items:
+                    new_folder = await self._render_browse_item(folder, path_parts=path_parts)
+                    if new_folder:
+                        items.append(new_folder)
+            return items
+        elif sub_sub_path:
+            # Date listings for a station
+            date_folders = [
+                BrowseFolder(
+                    item_id="today",
+                    name="Today",
+                    provider=self.domain,
+                    path="/".join([*path_parts, dt.now().strftime("%Y-%m-%d")]),
+                ),
+                BrowseFolder(
+                    item_id="yesterday",
+                    name="Yesterday",
+                    provider=self.domain,
+                    path="/".join(
+                        [
+                            *path_parts,
+                            (dt.now() - timedelta(days=1)).strftime("%Y-%m-%d"),
+                        ]
+                    ),
+                ),
+            ]
+            # Maximum is 30 days prior
+            for diff in range(28):
+                this_date = dt.now() - timedelta(days=2 + diff)
+                date_string = this_date.strftime("%Y-%m-%d")
+                date_folders.extend(
+                    [
+                        BrowseFolder(
+                            item_id=date_string,
+                            name=date_string,
+                            provider=self.domain,
+                            path="/".join([*path_parts, date_string]),
+                        )
+                    ]
+                )
+            return date_folders
+        else:
+            return [
+                BrowseFolder(
+                    item_id=station.item_id,
+                    provider=self.domain,
+                    name=station.name,
+                    path="/".join([*path_parts, station.item_id]),
+                    image=(
+                        MediaItemImage(
+                            type=ImageType.THUMB,
+                            path=station.metadata.images[0].path,
+                            provider=self.domain,
+                        )
+                        if station.metadata.images
+                        else None
+                    ),
+                )
+                for station in await self._station_list(include_local=show_local)
+            ]
+
+    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        """Browse this provider's items.
+
+        :param path: The path to browse, (e.g. provider_id://artists).
+        """
+        self.logger.debug(f"Browsing path: {path}")
+        if not path.startswith(f"{self.domain}://"):
+            raise MusicAssistantError(f"Invalid path for {self.domain} provider: {path}")
+        path_parts = path.split("://", 1)[1].split("/")
+        self.logger.debug(f"Path parts: {path_parts}")
+        sub_path = path_parts[0] if path_parts else ""
+        sub_sub_path = path_parts[1] if len(path_parts) > 1 else ""
+        sub_sub_sub_path = path_parts[2] if len(path_parts) > 2 else ""
+        path_parts = [
+            f"{self.domain}:/",
+            *[part for part in path_parts if len(part) > 0],
+        ]
+
+        if sub_path == "":
+            return await self._get_menu()
+        elif sub_path == "categories" and sub_sub_path:
+            return await self._get_category(sub_sub_path)
+        elif sub_path == "collections" and sub_sub_path:
+            return await self._get_collection(sub_sub_path)
+        elif sub_path != "stations":
+            return await self._get_subpath_menu(sub_path)
+        elif sub_path == "stations":
+            return await self._get_station_schedule_menu(
+                self.show_local_stations, path_parts, sub_sub_path, sub_sub_sub_path
+            )
+        else:
+            return []
+
+    async def search(
+        self, search_query: str, media_types: list[MediaType] | None, limit: int = 5
+    ) -> SearchResults:
+        """Perform search for BBC Sounds stations."""
+        results = SearchResults()
+        search_result = await self.client.streaming.search(search_query)
+        self.logger.debug(search_result)
+        if media_types is None or MediaType.RADIO in media_types:
+            radios = [await self.adaptor.new_object(radio) for radio in search_result.stations]
+            results.radio = [radio for radio in radios if isinstance(radio, Radio)]
+        if (
+            media_types is None
+            or MediaType.TRACK in media_types
+            or MediaType.PODCAST_EPISODE in media_types
+        ):
+            episodes = [await self.adaptor.new_object(track) for track in search_result.episodes]
+            results.tracks = [track for track in episodes if type(track) is Track]
+
+        if media_types is None or MediaType.PODCAST in media_types:
+            podcasts = [await self.adaptor.new_object(show) for show in search_result.shows]
+            results.podcasts = [podcast for podcast in podcasts if isinstance(podcast, Podcast)]
+
+        return results
+
+    @use_cache(expiration=_Constants.DEFAULT_EXPIRATION)
+    async def get_podcast(self, prov_podcast_id: str) -> Podcast:
+        """Get full podcast details by id."""
+        self.logger.debug(f"Getting podcast for {prov_podcast_id}")
+        podcast = await self.client.streaming.get_podcast(pid=prov_podcast_id)
+        ma_podcast = await self.adaptor.new_object(source_obj=podcast, force_type=Podcast)
+
+        if isinstance(ma_podcast, Podcast):
+            return ma_podcast
+        raise MusicAssistantError("Incorrect format for podcast")
+
+    @use_cache(expiration=_Constants.SHORT_EXPIRATION)  # type: ignore[arg-type]
+    async def get_podcast_episodes(  # type: ignore[override]
+        self,
+        prov_podcast_id: str,
+    ) -> AsyncGenerator[PodcastEpisode, None]:
+        """Get all PodcastEpisodes for given podcast id."""
+        podcast_episodes = await self.client.streaming.get_podcast_episodes(prov_podcast_id)
+
+        if podcast_episodes:
+            for episode in podcast_episodes:
+                this_episode = await self.adaptor.new_object(
+                    source_obj=episode, force_type=PodcastEpisode
+                )
+                if this_episode and isinstance(this_episode, PodcastEpisode):
+                    yield this_episode
+
+    @use_cache(expiration=_Constants.SHORT_EXPIRATION)
+    async def recommendations(self) -> list[RecommendationFolder]:
+        """Get available recommendations."""
+        folders = []
+
+        if self.client.auth.is_logged_in:
+            recommendations = await self.client.personal.get_experience_menu(
+                recommendations=MenuRecommendationOptions.ONLY
+            )
+            self.logger.debug("Getting recommendations from API")
+            if recommendations.sub_items:
+                for recommendation in recommendations.sub_items:
+                    # recommendation is a RecommendedMenuItem
+                    folder = await self.adaptor.new_object(
+                        recommendation, force_type=RecommendationFolder
+                    )
+                    if isinstance(folder, RecommendationFolder):
+                        folders.append(folder)
+            return folders
+        return []
+
+    async def get_radio(self, prov_radio_id: str) -> Radio:
+        """Get full radio details by id."""
+        self.logger.debug(f"Getting radio for {prov_radio_id}")
+        station = await self.client.stations.get_station(prov_radio_id, include_stream=True)
+        if station:
+            ma_radio = await self.adaptor.new_object(station, force_type=Radio)
+            if ma_radio and isinstance(ma_radio, Radio):
+                return ma_radio
+        else:
+            raise MusicAssistantError(f"No station found: {prov_radio_id}")
+
+        self.logger.debug(f"{station} {ma_radio} {type(ma_radio)}")
+        raise MusicAssistantError("No valid radio stream found")
+
+    async def on_played(
+        self,
+        media_type: MediaType,
+        prov_item_id: str,
+        fully_played: bool,
+        position: int,
+        media_item: MediaItemType,
+        is_playing: bool = False,
+    ) -> None:
+        """Handle callback when a (playable) media item has been played."""
+        if media_type != MediaType.RADIO:
+            # Handle Sounds API play status updates
+            action = None
+
+            if is_playing:
+                action = PlayStatus.STARTED if position < 30 else PlayStatus.HEARTBEAT
+            elif fully_played:
+                action = PlayStatus.ENDED
+            else:
+                action = PlayStatus.PAUSED
+
+            if action:
+                success = await self.client.streaming.update_play_status(
+                    pid=media_item.item_id, elapsed_time=position, action=action
+                )
+                self.logger.info(f"Updated play status: {success}")
+        # Cancel now playing task
+        if FEATURES["now_playing"] and not is_playing and self.current_task:
+            self.current_task.cancel()
diff --git a/music_assistant/providers/bbc_sounds/adaptor.py b/music_assistant/providers/bbc_sounds/adaptor.py
new file mode 100644 (file)
index 0000000..1365a8f
--- /dev/null
@@ -0,0 +1,875 @@
+"""Adaptor for converting BBC Sounds objects to Music Assistant media items.
+
+Many Sounds API endpoints return containers of "PlayableObjects" which can be a
+range of different types. The auntie-sounds library detects these differing
+types and provides a sensible set of objects to work with, e.g. RadioShow.
+
+This adaptor maps those objects to the most sensible type for MA.
+"""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from datetime import datetime, tzinfo
+from typing import TYPE_CHECKING, Any
+
+from music_assistant_models.enums import ContentType, ImageType, MediaType, StreamType
+from music_assistant_models.media_items import (
+    AudioFormat,
+    BrowseFolder,
+    MediaItemChapter,
+    MediaItemImage,
+    MediaItemMetadata,
+    ProviderMapping,
+    Radio,
+    RecommendationFolder,
+    Track,
+)
+from music_assistant_models.media_items import Podcast as MAPodcast
+from music_assistant_models.media_items import PodcastEpisode as MAPodcastEpisode
+from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
+from music_assistant_models.unique_list import UniqueList
+from sounds.models import (
+    Category,
+    Collection,
+    LiveStation,
+    MenuItem,
+    Podcast,
+    PodcastEpisode,
+    RadioClip,
+    RadioSeries,
+    RadioShow,
+    RecommendedMenuItem,
+    Schedule,
+    SoundsTypes,
+    Station,
+    StationSearchResult,
+)
+
+import music_assistant.helpers.datetime as dt
+from music_assistant.helpers.datetime import LOCAL_TIMEZONE
+
+if TYPE_CHECKING:
+    from music_assistant.providers.bbc_sounds import BBCSoundsProvider
+
+
+def _date_convertor(
+    timestamp: str | datetime,
+    date_format: str,
+    timezone: tzinfo | None = LOCAL_TIMEZONE,
+) -> str:
+    if isinstance(timestamp, str):
+        timestamp = dt.from_iso_string(timestamp)
+    else:
+        timestamp = timestamp.astimezone(timezone)
+    return timestamp.strftime(date_format)
+
+
+def _to_time(timestamp: str | datetime) -> str:
+    return _date_convertor(timestamp, "%H:%M")
+
+
+def _to_date_and_time(timestamp: str | datetime) -> str:
+    return _date_convertor(timestamp, "%a %d %B %H:%M")
+
+
+def _to_date(timestamp: str | datetime) -> str:
+    return _date_convertor(timestamp, "%d/%m/%y")
+
+
+class ConversionError(Exception):
+    """Raised when object conversion fails."""
+
+
+class ImageProvider:
+    """Handles image URL resolution and MediaItemImage creation."""
+
+    # TODO: keeping this in for demo purposes
+    ICON_BASE_URL = (
+        "https://cdn.jsdelivr.net/gh/kieranhogg/auntie-sounds@main/src/sounds/icons/solid"
+    )
+
+    ICON_MAPPING = {
+        "listen_live": "listen_live",
+        "continue_listening": "continue",
+        "editorial_collection": "editorial",
+        "local_rail": "my_location",
+        "single_item_promo": "featured",
+        "collections": "collections",
+        "categories": "categories",
+        "recommendations": "my_sounds",
+        "unmissable_speech": "speech",
+        "unmissable_music": "music",
+    }
+
+    @classmethod
+    def get_icon_url(cls, icon_id: str) -> str | None:
+        """Get icon URL for a given icon ID."""
+        if icon_id is not None:
+            if icon_id in cls.ICON_MAPPING:
+                return f"{cls.ICON_BASE_URL}/{cls.ICON_MAPPING[icon_id]}.png"
+            if "latest_playables_for_curation" in icon_id:
+                return f"{cls.ICON_BASE_URL}/news.png"
+        return None
+
+    @classmethod
+    def create_image(
+        cls, url: str, provider: str, image_type: ImageType = ImageType.THUMB
+    ) -> MediaItemImage:
+        """Create a MediaItemImage from a URL."""
+        return MediaItemImage(
+            path=url,
+            provider=provider,
+            type=image_type,
+            remotely_accessible=True,
+        )
+
+    @classmethod
+    def create_metadata_with_image(
+        cls,
+        url: str | None,
+        provider: str,
+        description: str | None = None,
+        chapters: list[MediaItemChapter] | None = None,
+    ) -> MediaItemMetadata:
+        """Create metadata with optional image and description."""
+        metadata = MediaItemMetadata()
+        if url:
+            metadata.add_image(cls.create_image(url, provider))
+        if description:
+            metadata.description = description
+        if chapters:
+            metadata.chapters = chapters
+        return metadata
+
+
+@dataclass
+class Context:
+    """Context information for object conversion."""
+
+    provider: "BBCSoundsProvider"
+    provider_domain: str
+    path_parts: list[str] | None = None
+    force_type: (
+        type[Track]
+        | type[LiveStation]
+        | type[Radio]
+        | type[MAPodcast]
+        | type[MAPodcastEpisode]
+        | type[BrowseFolder]
+        | type[RecommendationFolder]
+        | type[RecommendedMenuItem]
+        | None
+    ) = None
+
+
+class BaseConverter(ABC):
+    """Base model."""
+
+    def __init__(self, context: Context):
+        """Create a new instance."""
+        self.context = context
+        self.logger = self.context.provider.logger
+
+    @abstractmethod
+    def can_convert(self, source_obj: Any) -> bool:
+        """Check if this converter can handle the source object."""
+
+    @abstractmethod
+    async def get_stream_details(self, source_obj: Any) -> StreamDetails | None:
+        """Convert the source object to a stream."""
+
+    @abstractmethod
+    async def convert(
+        self, source_obj: Any
+    ) -> (
+        Track
+        | LiveStation
+        | Radio
+        | MAPodcast
+        | MAPodcastEpisode
+        | BrowseFolder
+        | RecommendationFolder
+        | RecommendedMenuItem
+    ):
+        """Convert the source object to target type."""
+
+    def _create_provider_mapping(self, item_id: str) -> ProviderMapping:
+        """Create provider mapping for the item."""
+        return self.context.provider._get_provider_mapping(item_id)
+
+    def _get_attr(self, obj: Any, attr_path: str, default: Any = None) -> Any:
+        """Get (optionally-nested) attribute from object.
+
+        Supports e.g. _get_attr(object, "thing.other_thing")
+        """
+        # TODO: I'm fairly sure there is existing code/libs for this?
+        try:
+            current = obj
+            for part in attr_path.split("."):
+                if hasattr(current, part):
+                    current = getattr(current, part)
+                elif isinstance(current, dict) and part in current:
+                    current = current[part]
+                else:
+                    return default
+            return current
+        except (AttributeError, KeyError, TypeError):
+            return default
+
+
+class StationConverter(BaseConverter):
+    """Converts Station-related objects."""
+
+    type ConvertableTypes = Station | LiveStation | StationSearchResult
+    convertable_types = (Station, LiveStation, StationSearchResult)
+
+    def can_convert(self, source_obj: ConvertableTypes) -> bool:
+        """Check if this converter can convert to a Station object."""
+        return isinstance(source_obj, self.convertable_types)
+
+    async def get_stream_details(self, source_obj: Station | LiveStation) -> StreamDetails | None:
+        """Convert the source object to a stream."""
+        from music_assistant.providers.bbc_sounds import (  # noqa: PLC0415
+            FEATURES,
+            _Constants,
+        )
+
+        # TODO: can't seek this stream
+        station = await self.convert(source_obj)
+        if not station or not source_obj.stream:
+            return None
+        show_time = self._get_attr(source_obj, "titles.secondary")
+        show_title = self._get_attr(source_obj, "titles.primary")
+        programme_name = f"{show_time} • {show_title}"
+        stream_details = None
+        if station and source_obj.stream:
+            if FEATURES["now_playing"]:
+                stream_metadata = StreamMetadata(
+                    title=programme_name,
+                )
+
+                if station.image is not None:
+                    stream_metadata.image_url = station.image.path
+            else:
+                stream_metadata = None
+
+            stream_details = StreamDetails(
+                stream_metadata=stream_metadata,
+                media_type=MediaType.RADIO,
+                stream_type=StreamType.HLS
+                if self.context.provider.stream_format == _Constants.HLS
+                else StreamType.HTTP,
+                path=str(source_obj.stream),
+                item_id=station.item_id,
+                provider=station.provider,
+                audio_format=AudioFormat(
+                    content_type=ContentType.try_parse(str(source_obj.stream))
+                ),
+                data={
+                    "provider": self.context.provider_domain,
+                    "station": station.item_id,
+                },
+            )
+        return stream_details
+
+    async def convert(self, source_obj: ConvertableTypes) -> Radio:
+        """Convert the source object to target type."""
+        if isinstance(source_obj, Station):
+            return self._convert_station(source_obj)
+        elif isinstance(source_obj, LiveStation):
+            return self._convert_live_station(source_obj)
+        elif isinstance(source_obj, StationSearchResult):
+            return self._convert_station_search_result(source_obj)
+        self.logger.error(f"Failed to convert station {type(source_obj)}: {source_obj}")
+        raise ConversionError(f"Failed to convert station {type(source_obj)}: {source_obj}")
+
+    def _convert_station(self, station: Station) -> Radio:
+        """Convert Station object."""
+        image_url = self._get_attr(station, "image_url")
+
+        radio = Radio(
+            item_id=station.item_id,
+            # Add BBC prefix back to station to help identify station within MA
+            name=f"BBC {self._get_attr(station, 'title', 'Unknown')}",
+            provider=self.context.provider_domain,
+            metadata=ImageProvider.create_metadata_with_image(
+                image_url, self.context.provider_domain
+            ),
+            provider_mappings={self._create_provider_mapping(station.item_id)},
+        )
+        if station.stream:
+            radio.uri = station.stream.uri
+        return radio
+
+    def _convert_live_station(self, station: LiveStation) -> Radio:
+        """Convert LiveStation object."""
+        network_id = self._get_attr(station, "network.id")
+        name = self._get_attr(station, "network.short_title", "Unknown")
+        image_url = self._get_attr(station, "network.logo_url")
+
+        return Radio(
+            item_id=network_id,
+            name=f"BBC {name}",
+            provider=self.context.provider_domain,
+            metadata=ImageProvider.create_metadata_with_image(
+                image_url, self.context.provider_domain
+            ),
+            provider_mappings={self._create_provider_mapping(network_id)},
+        )
+
+    def _convert_station_search_result(self, station: StationSearchResult) -> Radio:
+        """Convert StationSearchResult object."""
+        return Radio(
+            item_id=station.service_id,
+            name=f"BBC {station.station_name}",
+            provider=self.context.provider_domain,
+            metadata=ImageProvider.create_metadata_with_image(
+                station.station_image_url, self.context.provider_domain
+            ),
+            provider_mappings={self._create_provider_mapping(station.service_id)},
+        )
+
+
+class PodcastConverter(BaseConverter):
+    """Converts podcast-related objects."""
+
+    type ConvertableTypes = Podcast | PodcastEpisode | RadioShow | RadioClip | RadioSeries
+    convertable_types = (Podcast, PodcastEpisode, RadioShow, RadioClip, RadioSeries)
+    type OutputTypes = MAPodcast | MAPodcastEpisode | Track
+    output_types = MAPodcast | MAPodcastEpisode | Track
+    SCHEDULE_ITEM_FORMAT = "{start} {show_name} • {show_title} ({date})"
+    SCHEDULE_ITEM_DEFAULT_FORMAT = "{show_name} • {show_title}"
+    PODCAST_EPISODE_DEFAULT_FORMAT = "{episode_title} ({date})"
+    PODCAST_EPISODE_DETAILED_FORMAT = "{episode_title} • {detail} ({date})"
+
+    def _format_show_title(self, show: RadioShow) -> str:
+        if show is None:
+            return "Unknown show"
+        if show.start and show.titles:
+            return self.SCHEDULE_ITEM_FORMAT.format(
+                start=_to_time(show.start),
+                show_name=show.titles["primary"],
+                show_title=show.titles["secondary"],
+                date=_to_date(show.start),
+            )
+        elif show.titles:
+            # TODO: when getting a schedule listing, we have a broadcast time
+            # when we fetch the streaming details later we lose that from the new API call
+            title = self.SCHEDULE_ITEM_DEFAULT_FORMAT.format(
+                show_name=show.titles["primary"],
+                show_title=show.titles["secondary"],
+            )
+            date = show.release.get("date") if show.release else None
+            if date and isinstance(date, (str, datetime)):
+                title += f" ({_to_date(date)})"
+            return title
+        return "Unknown"
+
+    def _format_podcast_episode_title(self, episode: PodcastEpisode) -> str:
+        # Similar to show, but not quite: we expect to see this in the context of a podcast detail
+        # page
+        if episode is None:
+            return "Unknown episode"
+
+        if episode.release:
+            date = episode.release.get("date")
+        elif episode.availability:
+            date = episode.availability.get("from")
+        else:
+            date = None
+        if isinstance(date, (str, datetime)) and episode.titles:
+            datestamp = _to_date(date)
+            title = self.PODCAST_EPISODE_DEFAULT_FORMAT.format(
+                episode_title=episode.titles.get("secondary"),
+                date=datestamp,
+            )
+        else:
+            title = str(episode.titles.get("secondary")) if episode.titles else "Unknown episode"
+        return title
+
+    def can_convert(self, source_obj: ConvertableTypes) -> bool:
+        """Check if this converter can convert to a Podcast object."""
+        # Can't use type alias here https://github.com/python/mypy/issues/11673
+        if self.context.force_type:
+            return issubclass(self.context.force_type, self.output_types)
+        return isinstance(source_obj, self.convertable_types)
+
+    async def get_stream_details(self, source_obj: ConvertableTypes) -> StreamDetails | None:
+        """Convert the source object to a stream."""
+        from music_assistant.providers.bbc_sounds import _Constants  # noqa: PLC0415
+
+        if isinstance(source_obj, (Podcast, RadioSeries)):
+            return None
+        stream_details = None
+        episode = await self.convert(source_obj)
+        if (
+            episode
+            and isinstance(episode, MAPodcastEpisode)
+            and (episode.metadata.description or episode.name)
+            and source_obj.stream
+        ):
+            stream_details = StreamDetails(
+                stream_metadata=StreamMetadata(
+                    title=episode.metadata.description or episode.name,
+                    uri=source_obj.stream,
+                ),
+                media_type=MediaType.PODCAST_EPISODE,
+                stream_type=StreamType.HLS
+                if self.context.provider.stream_format == _Constants.HLS
+                else StreamType.HTTP,
+                path=source_obj.stream,
+                item_id=source_obj.id,
+                provider=self.context.provider_domain,
+                audio_format=AudioFormat(content_type=ContentType.try_parse(source_obj.stream)),
+                allow_seek=True,
+                can_seek=True,
+                duration=(episode.duration if episode.duration else None),
+                seek_position=(int(episode.position) if episode.position else 0),
+                seconds_streamed=(int(episode.position) if episode.position else 0),
+            )
+        elif episode and isinstance(episode, Track) and source_obj.stream:
+            metadata = StreamMetadata(
+                title=f"BBC {episode.metadata.description}", uri=source_obj.stream
+            )
+            if episode.metadata.images:
+                metadata.image_url = episode.metadata.images[0].path
+
+            stream_details = StreamDetails(
+                stream_metadata=metadata,
+                media_type=MediaType.TRACK,
+                stream_type=StreamType.HLS
+                if self.context.provider.stream_format == _Constants.HLS
+                else StreamType.HTTP,
+                path=source_obj.stream,
+                item_id=episode.item_id,
+                provider=self.context.provider_domain,
+                audio_format=AudioFormat(content_type=ContentType.try_parse(source_obj.stream)),
+                can_seek=True,
+                duration=episode.duration,
+            )
+        return stream_details
+
+    async def convert(self, source_obj: ConvertableTypes) -> OutputTypes:
+        """Convert podcast objects."""
+        if isinstance(source_obj, (Podcast, RadioSeries)) or self.context.force_type is Podcast:
+            return await self._convert_podcast(source_obj)
+        elif isinstance(source_obj, PodcastEpisode):
+            return await self._convert_podcast_episode(source_obj)
+        elif isinstance(source_obj, RadioShow):
+            return await self._convert_radio_show(source_obj)
+        elif isinstance(source_obj, RadioClip) or self.context.force_type is Track:
+            return await self._convert_radio_clip(source_obj)
+        return source_obj
+
+    async def _convert_podcast(self, podcast: Podcast | RadioSeries) -> MAPodcast:
+        name = self._get_attr(podcast, "titles.primary") or self._get_attr(podcast, "title")
+        description = self._get_attr(podcast, "synopses.long") or self._get_attr(
+            podcast, "synopses.short"
+        )
+        image_url = self._get_attr(podcast, "image_url") or self._get_attr(
+            podcast, "sub_items.image_url"
+        )
+
+        return MAPodcast(
+            item_id=podcast.id,
+            name=name,
+            provider=self.context.provider_domain,
+            metadata=ImageProvider.create_metadata_with_image(
+                image_url, self.context.provider_domain, description
+            ),
+            provider_mappings={self._create_provider_mapping(podcast.item_id)},
+        )
+
+    async def _convert_podcast_episode(self, episode: PodcastEpisode) -> MAPodcastEpisode:
+        duration = self._get_attr(episode, "duration.value")
+        progress_ms = self._get_attr(episode, "progress.value")
+        resume_position = (progress_ms * 1000) if progress_ms else None
+        description = self._get_attr(episode, "synopses.short")
+
+        # Handle parent podcast
+        podcast = None
+        if hasattr(episode, "container") and episode.container:
+            podcast = await PodcastConverter(self.context).convert(episode.container)
+
+        if not podcast or not isinstance(podcast, MAPodcast):
+            raise ConversionError(f"No podcast for episode {episode}")
+        if not episode or not episode.pid:
+            raise ConversionError(f"No podcast episode for {episode}")
+
+        return MAPodcastEpisode(
+            item_id=episode.pid,
+            name=self._format_podcast_episode_title(episode),
+            provider=self.context.provider_domain,
+            duration=duration,
+            position=0,
+            resume_position_ms=resume_position,
+            metadata=ImageProvider.create_metadata_with_image(
+                episode.image_url,
+                self.context.provider_domain,
+                description,
+            ),
+            podcast=podcast,
+            provider_mappings={self._create_provider_mapping(episode.pid)},
+            uri=episode.stream,
+        )
+
+    async def _convert_radio_show(self, show: RadioShow) -> MAPodcastEpisode | Track:
+        from music_assistant.providers.bbc_sounds import _Constants  # noqa: PLC0415
+
+        duration = self._get_attr(show, "duration.value")
+        progress_ms = self._get_attr(show, "progress.value")
+        resume_position = (progress_ms * 1000) if progress_ms else None
+
+        if not show or not show.pid:
+            raise ConversionError(f"No radio show for {show}")
+
+        # Determine if this should be an episode or track based on duration/context
+        # TODO: picked a sensible default but need to investigate if this makes sense
+        # Track example: latest BBC News, PodcastEpisode: latest episode of a radio show
+        if (
+            self.context.force_type == Track
+            or (
+                not self.context.force_type
+                and duration
+                and duration < _Constants.TRACK_DURATION_THRESHOLD
+            )
+            or (not hasattr(show, "container") or not show.container)
+        ):
+            return Track(
+                item_id=show.pid,
+                name=self._format_show_title(show),
+                provider=self.context.provider_domain,
+                duration=duration,
+                metadata=ImageProvider.create_metadata_with_image(
+                    url=show.image_url,
+                    provider=self.context.provider_domain,
+                    description=show.synopses.get("long") if show.synopses else None,
+                ),
+                provider_mappings={self._create_provider_mapping(show.pid)},
+            )
+        else:
+            # Handle as episode
+            podcast = None
+            if hasattr(show, "container") and show.container:
+                podcast = await PodcastConverter(self.context).convert(show.container)
+
+            if not podcast or not isinstance(podcast, MAPodcast):
+                raise ConversionError(f"No podcast for episode for {show}")
+
+            return MAPodcastEpisode(
+                item_id=show.pid,
+                name=self._format_show_title(show),
+                provider=self.context.provider_domain,
+                duration=duration,
+                resume_position_ms=resume_position,
+                metadata=ImageProvider.create_metadata_with_image(
+                    show.image_url, self.context.provider_domain
+                ),
+                podcast=podcast,
+                provider_mappings={self._create_provider_mapping(show.pid)},
+                position=1,
+            )
+
+    async def _convert_radio_clip(self, clip: RadioClip) -> Track | MAPodcastEpisode:
+        duration = self._get_attr(clip, "duration.value")
+        description = self._get_attr(clip, "network.short_title")
+
+        if not clip or not clip.pid:
+            raise ConversionError(f"No clip for {clip}")
+
+        if self.context.force_type is MAPodcastEpisode:
+            podcast = None
+            if hasattr(clip, "container") and clip.container:
+                podcast = await PodcastConverter(self.context).convert(clip.container)
+
+            if not podcast or not isinstance(podcast, MAPodcast):
+                raise ConversionError(f"No podcast for episode for {clip}")
+            return MAPodcastEpisode(
+                item_id=clip.pid,
+                name=self._get_attr(clip, "titles.entity_title", "Unknown title"),
+                provider=self.context.provider_domain,
+                duration=duration,
+                metadata=ImageProvider.create_metadata_with_image(
+                    clip.image_url, self.context.provider_domain, description
+                ),
+                provider_mappings={self._create_provider_mapping(clip.pid)},
+                podcast=podcast,
+                position=0,
+            )
+        else:
+            return Track(
+                item_id=clip.pid,
+                name=self._get_attr(clip, "titles.entity_title", "Unknown Track"),
+                provider=self.context.provider_domain,
+                duration=duration,
+                metadata=ImageProvider.create_metadata_with_image(
+                    clip.image_url, self.context.provider_domain, description
+                ),
+                provider_mappings={self._create_provider_mapping(clip.pid)},
+            )
+
+
+class BrowseConverter(BaseConverter):
+    """Converts browsable objects like menus, categories, collections."""
+
+    type ConvertableTypes = MenuItem | Category | Collection | Schedule | RecommendedMenuItem
+    convertable_types = (MenuItem, Category, Collection, Schedule, RecommendedMenuItem)
+    type OutputTypes = BrowseFolder | RecommendationFolder
+    output_types = (BrowseFolder, RecommendationFolder)
+
+    def can_convert(self, source_obj: ConvertableTypes) -> bool:
+        """Check if this converter can convert to a Browsable object."""
+        can_convert = False
+        if self.context.force_type:
+            can_convert = issubclass(self.context.force_type, self.output_types)
+        else:
+            can_convert = isinstance(source_obj, self.convertable_types)
+        return can_convert
+
+    async def get_stream_details(self, source_obj: ConvertableTypes) -> StreamDetails | None:
+        """Convert the source object to a stream."""
+        return None
+
+    async def convert(self, source_obj: ConvertableTypes) -> OutputTypes:
+        """Convert browsable objects."""
+        if isinstance(source_obj, MenuItem) and self.context.force_type is not RecommendationFolder:
+            return self._convert_menu_item(source_obj)
+        elif isinstance(source_obj, (Category, Collection)):
+            return self._convert_category_or_collection(source_obj)
+        elif isinstance(source_obj, Schedule):
+            return self._convert_schedule(source_obj)
+        elif isinstance(source_obj, RecommendedMenuItem):
+            return await self._convert_recommended_item(source_obj)
+        self.logger.error(f"Failed to convert browse object {type(source_obj)}: {source_obj}")
+        raise ConversionError(f"Browse conversion failed: {source_obj}")
+
+    def _convert_menu_item(self, item: MenuItem) -> BrowseFolder | RecommendationFolder:
+        """Convert MenuItem to BrowseFolder or RecommendationFolder."""
+        image_url = ImageProvider.get_icon_url(item.item_id)
+        image = (
+            ImageProvider.create_image(image_url, self.context.provider_domain)
+            if image_url
+            else None
+        )
+        if not item or not item.title:
+            raise ConversionError(f"No menu item {item}")
+        path = self._build_path(item.item_id)
+
+        return_type = BrowseFolder
+
+        if self.context.force_type is RecommendationFolder:
+            return_type = RecommendationFolder
+
+        return return_type(
+            item_id=item.item_id,
+            name=item.title,
+            provider=self.context.provider_domain,
+            path=path,
+            image=image,
+        )
+
+    def _convert_category_or_collection(self, item: Category | Collection) -> BrowseFolder:
+        """Convert Category or Collection to BrowseFolder."""
+        path_prefix = "categories" if isinstance(item, Category) else "collections"
+        path = f"{self.context.provider_domain}://{path_prefix}/{item.item_id}"
+
+        return BrowseFolder(
+            item_id=item.item_id,
+            name=self._get_attr(item, "titles.primary", "Untitled folder"),
+            provider=self.context.provider_domain,
+            path=path,
+            image=(
+                ImageProvider.create_image(item.image_url, self.context.provider_domain)
+                if item.image_url
+                else None
+            ),
+        )
+
+    def _convert_schedule(self, schedule: Schedule) -> BrowseFolder:
+        """Convert Schedule to BrowseFolder."""
+        return BrowseFolder(
+            item_id="schedule",
+            name="Schedule",
+            provider=self.context.provider_domain,
+            path=self._build_path("schedule"),
+        )
+
+    async def _convert_recommended_item(self, item: RecommendedMenuItem) -> RecommendationFolder:
+        """Convert RecommendedMenuItem to RecommendationFolder."""
+        if not item or not item.sub_items or not item.title:
+            raise ConversionError(f"Incorrect format for item {item}")
+
+        # TODO this is messy
+        new_adaptor = Adaptor(provider=self.context.provider)
+        items: list[Track | Radio | MAPodcast | MAPodcastEpisode | BrowseFolder] = []
+        for sub_item in item.sub_items:
+            new_item = await new_adaptor.new_object(sub_item)
+            if (
+                new_item is not None
+                and not isinstance(new_item, RecommendationFolder)
+                and not isinstance(new_item, RecommendedMenuItem)
+            ):
+                items.append(new_item)
+
+        return RecommendationFolder(
+            item_id=item.item_id,
+            name=item.title,
+            provider=self.context.provider_domain,
+            items=UniqueList(items),
+        )
+
+    def _build_path(self, item_id: str) -> str:
+        """Build path for browse items."""
+        if self.context.path_parts:
+            return "/".join([*self.context.path_parts, item_id])
+        return f"{self.context.provider_domain}://{item_id}"
+
+
+class Adaptor:
+    """An adaptor object to convert Sounds API objects into MA ones."""
+
+    def __init__(self, provider: "BBCSoundsProvider"):
+        """Create new adaptor."""
+        self.provider = provider
+        self.logger = self.provider.logger
+        self._converters: list[BaseConverter] = []
+
+    def _create_context(
+        self,
+        path_parts: list[str] | None = None,
+        force_type: (
+            type[Track]
+            | type[Any]
+            | type[Radio]
+            | type[Podcast]
+            | type[PodcastEpisode]
+            | type[BrowseFolder]
+            | type[RecommendationFolder]
+            | None
+        ) = None,
+    ) -> Context:
+        return Context(
+            provider=self.provider,
+            provider_domain=self.provider.domain,
+            path_parts=path_parts,
+            force_type=force_type,
+        )
+
+    async def new_streamable_object(
+        self,
+        source_obj: SoundsTypes,
+        force_type: type[Track] | type[Radio] | type[MAPodcastEpisode] | None = None,
+        path_parts: list[str] | None = None,
+    ) -> StreamDetails | None:
+        """
+        Convert an auntie-sounds object to appropriate Music Assistant object.
+
+        Args:
+            source_obj: The source object from Sounds API via auntie-sounds
+            force_type: Force conversion to specific type if the expected target type is known
+            path_parts: Path parts for browse items to construct the object's path
+
+        Returns:
+            Converted Music Assistant media item or None if no converter found
+        """
+        if source_obj is None:
+            return None
+
+        context = self._create_context(path_parts, force_type)
+
+        converters = [
+            StationConverter(context),
+            PodcastConverter(context),
+            BrowseConverter(context),
+        ]
+
+        for converter in converters:
+            if converter.can_convert(source_obj):
+                try:
+                    stream_details = await converter.get_stream_details(source_obj)
+                    self.provider.logger.debug(
+                        f"Successfully converted {type(source_obj).__name__}"
+                        f" to {type(stream_details).__name__}"
+                    )
+                    return stream_details
+                except Exception as e:
+                    self.provider.logger.error(
+                        f"Unexpected error in converter {type(converter).__name__}: {e}"
+                    )
+                    raise
+        self.provider.logger.warning(
+            f"No stream converter found for type {type(source_obj).__name__}"
+        )
+        return None
+
+    async def new_object(
+        self,
+        source_obj: SoundsTypes,
+        force_type: (
+            type[
+                Track
+                | Radio
+                | MAPodcast
+                | MAPodcastEpisode
+                | BrowseFolder
+                | RecommendationFolder
+                | RecommendedMenuItem
+            ]
+            | None
+        ) = None,
+        path_parts: list[str] | None = None,
+    ) -> (
+        Track
+        | Radio
+        | MAPodcast
+        | MAPodcastEpisode
+        | BrowseFolder
+        | RecommendationFolder
+        | RecommendedMenuItem
+        | None
+    ):
+        """
+        Convert an auntie-sounds object to appropriate Music Assistant object.
+
+        Args:
+            source_obj: The source object from Sounds API via auntie-sounds
+            force_type: Force conversion to specific type if the expected target type is known
+            path_parts: Path parts for browse items to construct the object's path
+
+        Returns:
+            Converted Music Assistant media item or None if no converter found
+        """
+        if source_obj is None:
+            return None
+
+        context = self._create_context(path_parts, force_type)
+
+        converters = [
+            StationConverter(context),
+            PodcastConverter(context),
+            BrowseConverter(context),
+        ]
+        for converter in converters:
+            self.logger.debug(f"Checking if converter {converter} can convert {type(source_obj)}")
+            if converter.can_convert(source_obj):
+                try:
+                    result = await converter.convert(source_obj)
+                    if context.force_type:
+                        assert type(result) is context.force_type, (
+                            f"Forced type to {context.force_type} but received {type(result)} "
+                            f"using {type(converter)}"
+                        )
+                    self.provider.logger.debug(
+                        f"Successfully converted {type(source_obj).__name__}"
+                        f" to {type(result).__name__} {result}"
+                    )
+                    return result
+                except Exception as e:
+                    self.provider.logger.error(
+                        f"Unexpected error in converter {type(converter).__name__}: {e}"
+                    )
+                    raise
+            self.logger.debug(f"Converter {converter} could not convert {type(source_obj)}")
+
+        self.logger.warning(f"No converter found for type {type(source_obj).__name__}")
+        return None
diff --git a/music_assistant/providers/bbc_sounds/icon.svg b/music_assistant/providers/bbc_sounds/icon.svg
new file mode 100644 (file)
index 0000000..5279955
--- /dev/null
@@ -0,0 +1,11 @@
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246 246">
+<path d="M0 0 C40.92 0 81.84 0 124 0 C124 81.18 124 162.36 124 246 C83.08 246 42.16 246 0 246 C0 164.82 0 83.64 0 0 Z " fill="#F96306" transform="translate(133,0)"/>
+<path d="M0 0 C20.13 0 40.26 0 61 0 C61 58.08 61 116.16 61 176 C40.87 176 20.74 176 0 176 C0 117.92 0 59.84 0 0 Z " fill="#D24716" transform="translate(53,35)"/>
+<path d="M0 0 C29 0 29 0 36.0390625 6.40234375 C37.55207825 8.58152771 37.55207825 8.58152771 38.45629883 10.51342773 C38.81156647 11.23585663 39.16683411 11.95828552 39.53286743 12.7026062 C40.07347305 13.80887863 40.07347305 13.80887863 40.625 14.9375 C41.46155558 16.4357899 42.30893703 17.92808387 43.16674805 19.41430664 C44.81809451 22.30474288 46.41228354 25.21454604 47.95556641 28.1640625 C50.29739934 32.61749918 52.98252804 36.781317 55.8125 40.9375 C56.62778354 42.19266816 57.43890401 43.45054904 58.24609375 44.7109375 C58.66745605 45.36674805 59.08881836 46.02255859 59.52294922 46.69824219 C60.01037598 47.45782227 60.49780273 48.21740234 61 49 C61.63309082 49.98645508 62.26618164 50.97291016 62.91845703 51.98925781 C65.07183384 55.34624063 67.22390594 58.70405421 69.375 62.0625 C70.05409424 63.12267334 70.73318848 64.18284668 71.43286133 65.27514648 C72.08150146 66.28907471 72.7301416 67.30300293 73.3984375 68.34765625 C73.97255371 69.24492432 74.54666992 70.14219238 75.13818359 71.06665039 C77.44393667 74.69943602 79.71954884 78.35127814 82 82 C82.33 54.94 82.66 27.88 83 0 C91.91 0 100.82 0 110 0 C110 41.91 110 83.82 110 127 C100.76 127 91.52 127 82 127 C77.4770198 120.53859972 73.14882058 114.15403531 69.0625 107.4375 C58.27916117 89.87696731 47.08191373 72.57895062 35.78369141 55.34619141 C35.19894043 54.45335449 34.61418945 53.56051758 34.01171875 52.640625 C33.50084717 51.86235352 32.98997559 51.08408203 32.46362305 50.28222656 C30.92664047 47.88561053 29.46483039 45.44138398 28 43 C27.67 70.72 27.34 98.44 27 127 C18.09 127 9.18 127 0 127 C0 85.09 0 43.18 0 0 Z " fill="#F96200" transform="translate(681,59)"/>
+<path d="M0 0 C66.86713287 0 66.86713287 0 80 6 C80.76570312 6.34804687 81.53140625 6.69609375 82.3203125 7.0546875 C94.73623948 13.32754259 103.311796 23.64104686 108.3125 36.5 C114.73801775 56.1902132 113.9027032 78.30912251 105.43359375 97.2578125 C98.51758461 110.48680001 87.148533 119.46219886 73.0703125 123.9296875 C48.86004443 130.81878817 27.30838495 127 0 127 C0 85.09 0 43.18 0 0 Z M28 25 C28 50.74 28 76.48 28 103 C52.40663425 104.27591851 52.40663425 104.27591851 73 94 C82.14040784 83.35623561 83.6087693 70.56914735 83 57 C82.00520824 47.123139 78.24741015 38.27216573 70.8984375 31.46875 C58.03573794 21.51736104 44.88355312 25 28 25 Z " fill="#FA6200" transform="translate(810,59)"/>
+<path d="M0 0 C13.50189423 10.57779214 22.49444844 24.53716973 24.7890625 41.80761719 C26.83365224 63.05809847 24.75820761 83.91143261 11 101 C0.18793549 113.68338336 -13.75857293 120.21871321 -30.27807617 121.72631836 C-48.8768807 123.00689179 -66.67485008 119.10204993 -81.125 106.875 C-94.71289686 94.12976341 -100.20677308 76.55462857 -100.96484375 58.39453125 C-101.36960791 39.46435061 -96.72746105 20.30953398 -83.453125 6.19921875 C-61.38638411 -13.58354418 -24.75755405 -17.02081841 0 0 Z M-63 25 C-69.5000722 34.17857964 -71.55440271 44.23735951 -71.4375 55.3125 C-71.42992676 56.31998291 -71.42235352 57.32746582 -71.41455078 58.36547852 C-71.12054921 71.69533829 -67.68474169 81.1597992 -58.3125 90.75 C-50.36278022 96.82556304 -40.70905808 98.14711161 -31 97 C-23.35635029 95.31140287 -17.00661351 92.10215257 -12 86 C-3.79457655 73.0431064 -2.23291929 58.49247383 -4.53515625 43.51953125 C-6.8946546 33.34661212 -10.68140507 24.58920695 -19.6875 18.8125 C-33.58963047 10.87134177 -52.3356557 12.18252206 -63 25 Z " fill="#FA6200" transform="translate(520,67)"/>
+<path d="M0 0 C9.57 0 19.14 0 29 0 C29.00410889 2.58312012 29.00821777 5.16624023 29.01245117 7.82763672 C29.02969543 16.38926844 29.07427513 24.95060353 29.13209057 33.51205254 C29.16629257 38.6998249 29.19145999 43.88730489 29.19555664 49.07519531 C29.19985414 54.08754945 29.22843704 59.0992448 29.27343178 64.11139297 C29.28636047 66.01777424 29.29073097 67.92423453 29.28615379 69.83065414 C29.17392729 85.94063457 29.17392729 85.94063457 37.3125 99.4375 C46.30338754 104.6647602 55.74033597 105.66792865 65.875 103 C70.86260224 101.03796529 74.27581969 97.55912041 77 93 C79.45927507 87.0470143 80.44458554 81.67619323 80.43237305 75.26733398 C80.44766052 74.03641273 80.44766052 74.03641273 80.46325684 72.78062439 C80.49314576 70.10237589 80.50261546 67.42448641 80.51171875 64.74609375 C80.52858336 62.87564427 80.54673598 61.00520599 80.56611633 59.13478088 C80.61342896 54.23749292 80.64335919 49.34025042 80.66955566 44.44281006 C80.69945615 39.43508936 80.7459537 34.42752503 80.79101562 29.41992188 C80.8768355 19.61334548 80.94273918 9.80678477 81 0 C90.24 0 99.48 0 109 0 C109.06808816 10.71044252 109.12299507 21.42073858 109.15543652 32.13134956 C109.17101578 37.10604086 109.19211066 42.08057493 109.22631836 47.05517578 C109.25919037 51.86627599 109.27691103 56.67722607 109.28463173 61.48843002 C109.29012572 63.31341791 109.30085231 65.13839866 109.31719017 66.96332169 C109.46064869 83.66943406 108.22958544 99.54604737 97.625 113.375 C86.99318478 124.00681522 73.94810407 129.13657161 59 130 C58.30648437 130.04125 57.61296875 130.0825 56.8984375 130.125 C41.36695593 130.57682492 26.10187219 126.49010701 14.6171875 115.77734375 C-0.66230876 99.86750523 -0.16474645 79.81328448 -0.09765625 59.08203125 C-0.09578737 57.37139426 -0.09436569 55.66075674 -0.09336853 53.95011902 C-0.08958037 49.48353556 -0.0797824 45.01698547 -0.06866455 40.55041504 C-0.05837015 35.97827607 -0.05385383 31.40613163 -0.04882812 26.83398438 C-0.03815621 17.88930602 -0.02059011 8.94466103 0 0 Z " fill="#FA6200" transform="translate(556,59)"/>
+<path d="M0 0 C0.999104 0.00161133 1.99820801 0.00322266 3.02758789 0.00488281 C15.83527406 0.2129223 26.56036035 2.62393018 38.0625 8.375 C38.0625 16.625 38.0625 24.875 38.0625 33.375 C35.87625 32.446875 33.69 31.51875 31.4375 30.5625 C17.42227223 24.87020381 1.79996042 21.34178301 -12.94140625 26.44921875 C-16.36368736 28.03646067 -18.27416585 30.0483317 -19.9375 33.375 C-20.20796812 36.95870253 -20.19688588 39.67790045 -18.9375 43.0625 C-13.64803466 49.1784443 -4.68165862 50.76853198 2.8125 52.8125 C32.85977834 61.32449615 32.85977834 61.32449615 40.87109375 74.12890625 C45.52070093 82.89455914 46.23191459 93.38792393 43.84765625 103.00390625 C40.36071456 113.5341232 35.1479651 120.43370665 25.3125 125.625 C4.68657671 135.15051876 -18.58491581 133.99956593 -39.73046875 126.69140625 C-42.79715821 125.4326068 -45.30255754 124.38544122 -47.9375 122.375 C-48.50512695 119.73950195 -48.50512695 119.73950195 -48.42578125 116.61328125 C-48.40966797 115.49501953 -48.39355469 114.37675781 -48.37695312 113.22460938 C-48.31411133 111.47374023 -48.31411133 111.47374023 -48.25 109.6875 C-48.22744141 108.50865234 -48.20488281 107.32980469 -48.18164062 106.11523438 C-48.12263901 103.20055452 -48.04036341 100.28840839 -47.9375 97.375 C-45.11997963 98.49659273 -42.31513673 99.63674821 -39.51953125 100.8125 C-28.95990777 105.22585243 -19.87715505 107.51255386 -8.375 107.6875 C-7.18729004 107.71432861 -7.18729004 107.71432861 -5.97558594 107.74169922 C0.21365272 107.73271443 6.58794713 106.8859829 11.72900391 103.1706543 C15.2019077 99.6364068 15.62054973 96.3091001 15.62890625 91.62109375 C14.61881943 87.61557704 12.27901576 85.10741343 8.78515625 83.00390625 C3.08790369 80.71998562 -2.55152534 78.89257613 -8.47436523 77.29516602 C-23.26294166 73.30159831 -37.96989839 69.03678638 -46.23046875 55.2109375 C-50.62768966 46.23640472 -50.41309789 34.74193228 -47.3125 25.375 C-42.70147801 14.6587921 -35.59578857 8.62663741 -24.9375 4.125 C-16.78258182 0.89701155 -8.7150721 -0.01477006 0 0 Z " fill="#FA6200" transform="translate(367.9375,56.625)"/>
+<path d="M0 0 C1 1 1 1 1.11352539 3.40844727 C1.10567017 4.97163696 1.10567017 4.97163696 1.09765625 6.56640625 C1.09282227 8.2534668 1.09282227 8.2534668 1.08789062 9.97460938 C1.07532227 11.75061523 1.07532227 11.75061523 1.0625 13.5625 C1.05798828 14.75037109 1.05347656 15.93824219 1.04882812 17.16210938 C1.03699675 20.10812226 1.02051287 23.05403681 1 26 C0.24025879 25.69513672 -0.51948242 25.39027344 -1.30224609 25.07617188 C-16.88823569 18.858702 -33.33778174 13.37757216 -49.96875 19.07421875 C-53.41627345 20.64549386 -55.32987586 22.65975172 -57 26 C-57.35213732 30.2960753 -57.55573015 32.88853969 -55.625 36.75 C-48.24885217 43.07241243 -36.99998054 44.82890719 -27.83398438 47.35546875 C-15.28526766 50.89957916 -3.44526733 55.24846847 3.80859375 66.65625 C8.73380669 76.34519348 8.80300834 89.82136778 5.5 100.125 C0.7421235 110.85517602 -8.30489188 117.52218818 -19 121.75 C-41.15640516 128.30330294 -64.67433796 125.16283102 -85 115 C-85 106.75 -85 98.5 -85 90 C-82.17577989 91.12968804 -79.355046 92.26848764 -76.546875 93.4375 C-75.28130335 93.95846296 -74.01567726 94.47929367 -72.75 95 C-72.13253906 95.26039063 -71.51507812 95.52078125 -70.87890625 95.7890625 C-62.64098771 99.15217151 -54.81159784 100.34644835 -45.9375 100.3125 C-45.09123047 100.32861328 -44.24496094 100.34472656 -43.37304688 100.36132812 C-35.48987666 100.36740613 -28.84065311 98.66366363 -23 93 C-21.42162668 88.26488005 -21.40638704 84.56213937 -23.1875 79.875 C-27.86354582 73.84703618 -37.51046064 72.42238131 -44.51171875 70.37890625 C-75.45479165 61.34502781 -75.45479165 61.34502781 -83.875 46.75 C-87.38927273 38.15955555 -87.66380413 26.1849002 -84.375 17.4375 C-78.35707503 5.08712436 -69.73329828 -0.25813288 -57.11328125 -5.01953125 C-39.02679293 -10.71552695 -17.07851453 -7.36200044 0 0 Z " fill="#FA6200" transform="translate(1017,64)"/>
+<path d="M0 0 C11.22 0 22.44 0 34 0 C34 23.1 34 46.2 34 70 C22.78 70 11.56 70 0 70 C0 46.9 0 23.8 0 0 Z " fill="#A03207" transform="translate(0,88)"/>
+</svg>
diff --git a/music_assistant/providers/bbc_sounds/manifest.json b/music_assistant/providers/bbc_sounds/manifest.json
new file mode 100644 (file)
index 0000000..d96de02
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "type": "music",
+  "domain": "bbc_sounds",
+  "stage": "beta",
+  "name": "BBC Sounds",
+  "description": "Stream live BBC radio shows, podcast series and on-demand audio.",
+  "codeowners": [
+    "@kieranhogg"
+  ],
+  "requirements": [
+    "auntie-sounds==1.1.1"
+  ],
+  "multi_instance": false
+}
index 96085f7d057e8a2a3433c066c70fc8a574b466ea..d5d850a768b436a5ceb215a70761a2a823444435 100644 (file)
@@ -18,6 +18,7 @@ aiovban>=0.6.3
 alexapy==1.29.10
 async-upnp-client==0.45.0
 audible==0.10.0
+auntie-sounds==1.1.1
 bidict==0.23.1
 certifi==2025.11.12
 chardet>=5.2.0