music-assistant-server

33.7 KBPY
adaptor.py
33.7 KB882 lines • python
1"""Adaptor for converting BBC Sounds objects to Music Assistant media items.
2
3Many Sounds API endpoints return containers of "PlayableObjects" which can be a
4range of different types. The auntie-sounds library detects these differing
5types and provides a sensible set of objects to work with, e.g. RadioShow.
6
7This adaptor maps those objects to the most sensible type for MA.
8"""
9
10from abc import ABC, abstractmethod
11from dataclasses import dataclass
12from datetime import datetime, tzinfo
13from typing import TYPE_CHECKING, Any
14
15from music_assistant_models.enums import ContentType, ImageType, MediaType, StreamType
16from music_assistant_models.media_items import (
17    AudioFormat,
18    BrowseFolder,
19    MediaItemChapter,
20    MediaItemImage,
21    MediaItemMetadata,
22    ProviderMapping,
23    Radio,
24    RecommendationFolder,
25    Track,
26)
27from music_assistant_models.media_items import Podcast as MAPodcast
28from music_assistant_models.media_items import PodcastEpisode as MAPodcastEpisode
29from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
30from music_assistant_models.unique_list import UniqueList
31from sounds.models import (
32    Category,
33    Collection,
34    LiveStation,
35    MenuItem,
36    Podcast,
37    PodcastEpisode,
38    RadioClip,
39    RadioSeries,
40    RadioShow,
41    RecommendedMenuItem,
42    Schedule,
43    SoundsTypes,
44    Station,
45    StationSearchResult,
46)
47
48import music_assistant.helpers.datetime as dt
49from music_assistant.helpers.datetime import LOCAL_TIMEZONE
50
51if TYPE_CHECKING:
52    from music_assistant.providers.bbc_sounds import BBCSoundsProvider
53
54
55def _date_convertor(
56    timestamp: str | datetime,
57    date_format: str,
58    timezone: tzinfo | None = LOCAL_TIMEZONE,
59) -> str:
60    if isinstance(timestamp, str):
61        timestamp = dt.from_iso_string(timestamp)
62    else:
63        timestamp = timestamp.astimezone(timezone)
64    return timestamp.strftime(date_format)
65
66
67def _to_time(timestamp: str | datetime) -> str:
68    return _date_convertor(timestamp, "%H:%M")
69
70
71def _to_date_and_time(timestamp: str | datetime) -> str:
72    return _date_convertor(timestamp, "%a %d %B %H:%M")
73
74
75def _to_date(timestamp: str | datetime) -> str:
76    return _date_convertor(timestamp, "%d/%m/%y")
77
78
79class ConversionError(Exception):
80    """Raised when object conversion fails."""
81
82
83class ImageProvider:
84    """Handles image URL resolution and MediaItemImage creation."""
85
86    # TODO: keeping this in for demo purposes
87    ICON_BASE_URL = (
88        "https://cdn.jsdelivr.net/gh/kieranhogg/auntie-sounds@main/src/sounds/icons/solid"
89    )
90
91    ICON_MAPPING = {
92        "listen_live": "listen_live",
93        "continue_listening": "continue",
94        "editorial_collection": "editorial",
95        "local_rail": "my_location",
96        "single_item_promo": "featured",
97        "collections": "collections",
98        "categories": "categories",
99        "recommendations": "my_sounds",
100        "unmissable_speech": "speech",
101        "unmissable_music": "music",
102    }
103
104    @classmethod
105    def get_icon_url(cls, icon_id: str) -> str | None:
106        """Get icon URL for a given icon ID."""
107        if icon_id is not None:
108            if icon_id in cls.ICON_MAPPING:
109                return f"{cls.ICON_BASE_URL}/{cls.ICON_MAPPING[icon_id]}.png"
110            if "latest_playables_for_curation" in icon_id:
111                return f"{cls.ICON_BASE_URL}/news.png"
112        return None
113
114    @classmethod
115    def create_image(
116        cls, url: str, provider: str, image_type: ImageType = ImageType.THUMB
117    ) -> MediaItemImage:
118        """Create a MediaItemImage from a URL."""
119        return MediaItemImage(
120            path=url,
121            provider=provider,
122            type=image_type,
123            remotely_accessible=True,
124        )
125
126    @classmethod
127    def create_metadata_with_image(
128        cls,
129        url: str | None,
130        provider: str,
131        description: str | None = None,
132        chapters: list[MediaItemChapter] | None = None,
133    ) -> MediaItemMetadata:
134        """Create metadata with optional image and description."""
135        metadata = MediaItemMetadata()
136        if url:
137            metadata.add_image(cls.create_image(url, provider))
138        if description:
139            metadata.description = description
140        if chapters:
141            metadata.chapters = chapters
142        return metadata
143
144
145@dataclass
146class Context:
147    """Context information for object conversion."""
148
149    provider: "BBCSoundsProvider"
150    provider_domain: str
151    path_parts: list[str] | None = None
152    force_type: (
153        type[Track]
154        | type[LiveStation]
155        | type[Radio]
156        | type[MAPodcast]
157        | type[MAPodcastEpisode]
158        | type[BrowseFolder]
159        | type[RecommendationFolder]
160        | type[RecommendedMenuItem]
161        | None
162    ) = None
163
164
165class BaseConverter(ABC):
166    """Base model."""
167
168    def __init__(self, context: Context):
169        """Create a new instance."""
170        self.context = context
171        self.logger = self.context.provider.logger
172
173    @abstractmethod
174    def can_convert(self, source_obj: Any) -> bool:
175        """Check if this converter can handle the source object."""
176
177    @abstractmethod
178    async def get_stream_details(self, source_obj: Any) -> StreamDetails | None:
179        """Convert the source object to a stream."""
180
181    @abstractmethod
182    async def convert(
183        self, source_obj: Any
184    ) -> (
185        Track
186        | LiveStation
187        | Radio
188        | MAPodcast
189        | MAPodcastEpisode
190        | BrowseFolder
191        | RecommendationFolder
192        | RecommendedMenuItem
193    ):
194        """Convert the source object to target type."""
195
196    def _create_provider_mapping(self, item_id: str) -> ProviderMapping:
197        """Create provider mapping for the item."""
198        return self.context.provider._get_provider_mapping(item_id)
199
200    def _get_attr(self, obj: Any, attr_path: str, default: Any = None) -> Any:
201        """Get (optionally-nested) attribute from object.
202
203        Supports e.g. _get_attr(object, "thing.other_thing")
204        """
205        # TODO: I'm fairly sure there is existing code/libs for this?
206        try:
207            current = obj
208            for part in attr_path.split("."):
209                if hasattr(current, part):
210                    current = getattr(current, part)
211                elif isinstance(current, dict) and part in current:
212                    current = current[part]
213                else:
214                    return default
215            return current
216        except (AttributeError, KeyError, TypeError):
217            return default
218
219
220class StationConverter(BaseConverter):
221    """Converts Station-related objects."""
222
223    type ConvertableTypes = Station | LiveStation | StationSearchResult
224    convertable_types = (Station, LiveStation, StationSearchResult)
225
226    def can_convert(self, source_obj: ConvertableTypes) -> bool:
227        """Check if this converter can convert to a Station object."""
228        return isinstance(source_obj, self.convertable_types)
229
230    async def get_stream_details(self, source_obj: Station | LiveStation) -> StreamDetails | None:
231        """Convert the source object to a stream."""
232        from music_assistant.providers.bbc_sounds import FEATURES, _Constants  # noqa: PLC0415
233
234        # TODO: can't seek this stream
235        station = await self.convert(source_obj)
236        if not station or not source_obj.stream:
237            return None
238        show_time = self._get_attr(source_obj, "titles.secondary")
239        show_title = self._get_attr(source_obj, "titles.primary")
240        programme_name = f"{show_time} • {show_title}"
241        stream_details = None
242        if station and source_obj.stream:
243            if FEATURES["now_playing"]:
244                stream_metadata = StreamMetadata(
245                    title=programme_name,
246                )
247
248                if station.image is not None:
249                    stream_metadata.image_url = station.image.path
250            else:
251                stream_metadata = None
252
253            stream_details = StreamDetails(
254                stream_metadata=stream_metadata,
255                media_type=MediaType.RADIO,
256                stream_type=StreamType.HLS
257                if self.context.provider.stream_format == _Constants.HLS
258                else StreamType.HTTP,
259                path=str(source_obj.stream),
260                item_id=station.item_id,
261                provider=station.provider,
262                audio_format=AudioFormat(
263                    content_type=ContentType.try_parse(str(source_obj.stream))
264                ),
265                data={
266                    "provider": self.context.provider_domain,
267                    "station": station.item_id,
268                },
269            )
270        return stream_details
271
272    async def convert(self, source_obj: ConvertableTypes) -> Radio:
273        """Convert the source object to target type."""
274        if isinstance(source_obj, Station):
275            return self._convert_station(source_obj)
276        if isinstance(source_obj, LiveStation):
277            return self._convert_live_station(source_obj)
278        if isinstance(source_obj, StationSearchResult):
279            return self._convert_station_search_result(source_obj)
280        self.logger.error(f"Failed to convert station {type(source_obj)}: {source_obj}")
281        raise ConversionError(f"Failed to convert station {type(source_obj)}: {source_obj}")
282
283    def _convert_station(self, station: Station) -> Radio:
284        """Convert Station object."""
285        image_url = self._get_attr(station, "image_url")
286
287        radio = Radio(
288            item_id=station.id,
289            # Add BBC prefix back to station to help identify station within MA
290            name=f"BBC {self._get_attr(station, 'title', 'Unknown')}",
291            provider=self.context.provider_domain,
292            metadata=ImageProvider.create_metadata_with_image(
293                image_url, self.context.provider_domain
294            ),
295            provider_mappings={self._create_provider_mapping(station.id)},
296        )
297        if station.stream:
298            radio.uri = station.stream.uri
299        return radio
300
301    def _convert_live_station(self, station: LiveStation) -> Radio:
302        """Convert LiveStation object."""
303        name = self._get_attr(station, "network.short_title", "Unknown")
304        image_url = self._get_attr(station, "network.logo_url")
305
306        return Radio(
307            item_id=station.id,
308            name=f"BBC {name}",
309            provider=self.context.provider_domain,
310            metadata=ImageProvider.create_metadata_with_image(
311                image_url, self.context.provider_domain
312            ),
313            provider_mappings={self._create_provider_mapping(station.id)},
314        )
315
316    def _convert_station_search_result(self, station: StationSearchResult) -> Radio:
317        """Convert StationSearchResult object."""
318        return Radio(
319            item_id=station.service_id,
320            name=f"BBC {station.station_name}",
321            provider=self.context.provider_domain,
322            metadata=ImageProvider.create_metadata_with_image(
323                station.station_image_url, self.context.provider_domain
324            ),
325            provider_mappings={self._create_provider_mapping(station.service_id)},
326        )
327
328
329class PodcastConverter(BaseConverter):
330    """Converts podcast-related objects."""
331
332    type ConvertableTypes = Podcast | PodcastEpisode | RadioShow | RadioClip | RadioSeries
333    convertable_types = (Podcast, PodcastEpisode, RadioShow, RadioClip, RadioSeries)
334    type OutputTypes = MAPodcast | MAPodcastEpisode | Track
335    output_types = MAPodcast | MAPodcastEpisode | Track
336    SCHEDULE_ITEM_FORMAT = "{start} {show_name} • {show_title} ({date})"
337    SCHEDULE_ITEM_DEFAULT_FORMAT = "{show_name} • {show_title}"
338    PODCAST_EPISODE_DEFAULT_FORMAT = "{episode_title} ({date})"
339    PODCAST_EPISODE_DETAILED_FORMAT = "{episode_title} • {detail} ({date})"
340
341    def _format_show_title(self, show: RadioShow) -> str:
342        if show is None:
343            return "Unknown show"
344        if show.start and show.titles:
345            return self.SCHEDULE_ITEM_FORMAT.format(
346                start=_to_time(show.start),
347                show_name=show.titles["primary"],
348                show_title=show.titles["secondary"],
349                date=_to_date(show.start),
350            )
351        if show.titles:
352            # TODO: when getting a schedule listing, we have a broadcast time
353            # when we fetch the streaming details later we lose that from the new API call
354            title = self.SCHEDULE_ITEM_DEFAULT_FORMAT.format(
355                show_name=show.titles["primary"],
356                show_title=show.titles["secondary"],
357            )
358            date = show.release.get("date") if show.release else None
359            if date and isinstance(date, (str, datetime)):
360                title += f" ({_to_date(date)})"
361            return title
362        return "Unknown"
363
364    def _format_podcast_episode_title(self, episode: PodcastEpisode) -> str:
365        # Similar to show, but not quite: we expect to see this in the context of a podcast detail
366        # page
367        if episode is None:
368            return "Unknown episode"
369
370        if episode.release:
371            date = episode.release.get("date")
372        elif episode.availability:
373            date = episode.availability.get("from")
374        else:
375            date = None
376        if isinstance(date, (str, datetime)) and episode.titles:
377            datestamp = _to_date(date)
378            title = self.PODCAST_EPISODE_DEFAULT_FORMAT.format(
379                episode_title=episode.titles.get("secondary"),
380                date=datestamp,
381            )
382        else:
383            title = str(episode.titles.get("secondary")) if episode.titles else "Unknown episode"
384        return title
385
386    def can_convert(self, source_obj: ConvertableTypes) -> bool:
387        """Check if this converter can convert to a Podcast object."""
388        # Can't use type alias here https://github.com/python/mypy/issues/11673
389        if self.context.force_type:
390            return issubclass(self.context.force_type, self.output_types)
391        return isinstance(source_obj, self.convertable_types)
392
393    async def get_stream_details(self, source_obj: ConvertableTypes) -> StreamDetails | None:
394        """Convert the source object to a stream."""
395        from music_assistant.providers.bbc_sounds import _Constants  # noqa: PLC0415
396
397        if isinstance(source_obj, (Podcast, RadioSeries)):
398            return None
399        stream_details = None
400        episode = await self.convert(source_obj)
401        if (
402            episode
403            and isinstance(episode, MAPodcastEpisode)
404            and (episode.metadata.description or episode.name)
405            and source_obj.stream
406        ):
407            stream_details = StreamDetails(
408                stream_metadata=StreamMetadata(
409                    title=episode.metadata.description or episode.name,
410                    uri=source_obj.stream,
411                ),
412                media_type=MediaType.PODCAST_EPISODE,
413                stream_type=StreamType.HLS
414                if self.context.provider.stream_format == _Constants.HLS
415                else StreamType.HTTP,
416                path=source_obj.stream,
417                item_id=source_obj.id,
418                provider=self.context.provider_domain,
419                audio_format=AudioFormat(content_type=ContentType.try_parse(source_obj.stream)),
420                allow_seek=True,
421                can_seek=True,
422                duration=(episode.duration if episode.duration else None),
423                seek_position=(int(episode.position) if episode.position else 0),
424                seconds_streamed=(int(episode.position) if episode.position else 0),
425            )
426        elif episode and isinstance(episode, Track) and source_obj.stream:
427            # Try to work out the best network/series name to display
428            if source_obj.network and source_obj.network.id == "bbc_webonly":
429                title = "BBC News"
430            elif source_obj.network:
431                title = f"BBC {source_obj.network.short_title}"
432            elif source_obj.container:
433                title = source_obj.container.title
434            elif episode.metadata and episode.metadata.description:
435                title = episode.metadata.description
436            elif source_obj.titles:
437                title = source_obj.titles["primary"]
438            else:
439                title = ""
440
441            metadata = StreamMetadata(title=title, uri=source_obj.stream)
442            if episode.metadata.images:
443                metadata.image_url = episode.metadata.images[0].path
444
445            stream_details = StreamDetails(
446                stream_metadata=metadata,
447                media_type=MediaType.TRACK,
448                stream_type=StreamType.HLS
449                if self.context.provider.stream_format == _Constants.HLS
450                else StreamType.HTTP,
451                path=source_obj.stream,
452                item_id=episode.item_id,
453                provider=self.context.provider_domain,
454                audio_format=AudioFormat(content_type=ContentType.try_parse(source_obj.stream)),
455                can_seek=True,
456                duration=episode.duration,
457            )
458        return stream_details
459
460    async def convert(self, source_obj: ConvertableTypes) -> OutputTypes:
461        """Convert podcast objects."""
462        if isinstance(source_obj, (Podcast, RadioSeries)) or self.context.force_type is Podcast:
463            return await self._convert_podcast(source_obj)
464        if isinstance(source_obj, PodcastEpisode):
465            return await self._convert_podcast_episode(source_obj)
466        if isinstance(source_obj, RadioShow):
467            return await self._convert_radio_show(source_obj)
468        if isinstance(source_obj, RadioClip) or self.context.force_type is Track:
469            return await self._convert_radio_clip(source_obj)
470        return source_obj
471
472    async def _convert_podcast(self, podcast: Podcast | RadioSeries) -> MAPodcast:
473        name = self._get_attr(podcast, "titles.primary") or self._get_attr(podcast, "title")
474        description = self._get_attr(podcast, "synopses.long") or self._get_attr(
475            podcast, "synopses.short"
476        )
477        image_url = self._get_attr(podcast, "image_url") or self._get_attr(
478            podcast, "sub_items.image_url"
479        )
480
481        return MAPodcast(
482            item_id=podcast.id,
483            name=name,
484            provider=self.context.provider_domain,
485            metadata=ImageProvider.create_metadata_with_image(
486                image_url, self.context.provider_domain, description
487            ),
488            provider_mappings={self._create_provider_mapping(podcast.item_id)},
489        )
490
491    async def _convert_podcast_episode(self, episode: PodcastEpisode) -> MAPodcastEpisode:
492        duration = self._get_attr(episode, "duration.value")
493        progress_ms = self._get_attr(episode, "progress.value")
494        resume_position = (progress_ms * 1000) if progress_ms else None
495        description = self._get_attr(episode, "synopses.short")
496
497        # Handle parent podcast
498        podcast = None
499        if hasattr(episode, "container") and episode.container:
500            podcast = await PodcastConverter(self.context).convert(episode.container)
501
502        if not podcast or not isinstance(podcast, MAPodcast):
503            raise ConversionError(f"No podcast for episode {episode}")
504        if not episode or not episode.pid:
505            raise ConversionError(f"No podcast episode for {episode}")
506
507        return MAPodcastEpisode(
508            item_id=episode.pid,
509            name=self._format_podcast_episode_title(episode),
510            provider=self.context.provider_domain,
511            duration=duration,
512            position=0,
513            resume_position_ms=resume_position,
514            metadata=ImageProvider.create_metadata_with_image(
515                episode.image_url,
516                self.context.provider_domain,
517                description,
518            ),
519            podcast=podcast,
520            provider_mappings={self._create_provider_mapping(episode.pid)},
521            uri=episode.stream,
522        )
523
524    async def _convert_radio_show(self, show: RadioShow) -> MAPodcastEpisode | Track:
525        from music_assistant.providers.bbc_sounds import _Constants  # noqa: PLC0415
526
527        duration = self._get_attr(show, "duration.value")
528        progress_ms = self._get_attr(show, "progress.value")
529        resume_position = (progress_ms * 1000) if progress_ms else None
530
531        if not show or not show.pid:
532            raise ConversionError(f"No radio show for {show}")
533
534        # Determine if this should be an episode or track based on duration/context
535        # TODO: picked a sensible default but need to investigate if this makes sense
536        # Track example: latest BBC News, PodcastEpisode: latest episode of a radio show
537        if (
538            self.context.force_type == Track
539            or (
540                not self.context.force_type
541                and duration
542                and duration < _Constants.TRACK_DURATION_THRESHOLD
543            )
544            or (not hasattr(show, "container") or not show.container)
545        ):
546            return Track(
547                item_id=show.pid,
548                name=self._format_show_title(show),
549                provider=self.context.provider_domain,
550                duration=duration,
551                metadata=ImageProvider.create_metadata_with_image(
552                    url=show.image_url,
553                    provider=self.context.provider_domain,
554                    description=show.synopses.get("long") if show.synopses else None,
555                ),
556                provider_mappings={self._create_provider_mapping(show.pid)},
557            )
558        # Handle as episode
559        podcast = None
560        if hasattr(show, "container") and show.container:
561            podcast = await PodcastConverter(self.context).convert(show.container)
562
563        if not podcast or not isinstance(podcast, MAPodcast):
564            raise ConversionError(f"No podcast for episode for {show}")
565
566        return MAPodcastEpisode(
567            item_id=show.pid,
568            name=self._format_show_title(show),
569            provider=self.context.provider_domain,
570            duration=duration,
571            resume_position_ms=resume_position,
572            metadata=ImageProvider.create_metadata_with_image(
573                show.image_url, self.context.provider_domain
574            ),
575            podcast=podcast,
576            provider_mappings={self._create_provider_mapping(show.pid)},
577            position=1,
578        )
579
580    async def _convert_radio_clip(self, clip: RadioClip) -> Track | MAPodcastEpisode:
581        duration = self._get_attr(clip, "duration.value")
582        description = self._get_attr(clip, "network.short_title")
583
584        if not clip or not clip.pid:
585            raise ConversionError(f"No clip for {clip}")
586
587        if self.context.force_type is MAPodcastEpisode:
588            podcast = None
589            if hasattr(clip, "container") and clip.container:
590                podcast = await PodcastConverter(self.context).convert(clip.container)
591
592            if not podcast or not isinstance(podcast, MAPodcast):
593                raise ConversionError(f"No podcast for episode for {clip}")
594            return MAPodcastEpisode(
595                item_id=clip.pid,
596                name=self._get_attr(clip, "titles.entity_title", "Unknown title"),
597                provider=self.context.provider_domain,
598                duration=duration,
599                metadata=ImageProvider.create_metadata_with_image(
600                    clip.image_url, self.context.provider_domain, description
601                ),
602                provider_mappings={self._create_provider_mapping(clip.pid)},
603                podcast=podcast,
604                position=0,
605            )
606        return Track(
607            item_id=clip.pid,
608            name=self._get_attr(clip, "titles.entity_title", "Unknown Track"),
609            provider=self.context.provider_domain,
610            duration=duration,
611            metadata=ImageProvider.create_metadata_with_image(
612                clip.image_url, self.context.provider_domain, description
613            ),
614            provider_mappings={self._create_provider_mapping(clip.pid)},
615        )
616
617
618class BrowseConverter(BaseConverter):
619    """Converts browsable objects like menus, categories, collections."""
620
621    type ConvertableTypes = MenuItem | Category | Collection | Schedule | RecommendedMenuItem
622    convertable_types = (MenuItem, Category, Collection, Schedule, RecommendedMenuItem)
623    type OutputTypes = BrowseFolder | RecommendationFolder
624    output_types = (BrowseFolder, RecommendationFolder)
625
626    def can_convert(self, source_obj: ConvertableTypes) -> bool:
627        """Check if this converter can convert to a Browsable object."""
628        can_convert = False
629        if self.context.force_type:
630            can_convert = issubclass(self.context.force_type, self.output_types)
631        else:
632            can_convert = isinstance(source_obj, self.convertable_types)
633        return can_convert
634
635    async def get_stream_details(self, source_obj: ConvertableTypes) -> StreamDetails | None:
636        """Convert the source object to a stream."""
637        return None
638
639    async def convert(self, source_obj: ConvertableTypes) -> OutputTypes:
640        """Convert browsable objects."""
641        if isinstance(source_obj, MenuItem) and self.context.force_type is not RecommendationFolder:
642            return self._convert_menu_item(source_obj)
643        if isinstance(source_obj, (Category, Collection)):
644            return self._convert_category_or_collection(source_obj)
645        if isinstance(source_obj, Schedule):
646            return self._convert_schedule(source_obj)
647        if isinstance(source_obj, RecommendedMenuItem):
648            return await self._convert_recommended_item(source_obj)
649        self.logger.error(f"Failed to convert browse object {type(source_obj)}: {source_obj}")
650        raise ConversionError(f"Browse conversion failed: {source_obj}")
651
652    def _convert_menu_item(self, item: MenuItem) -> BrowseFolder | RecommendationFolder:
653        """Convert MenuItem to BrowseFolder or RecommendationFolder."""
654        image_url = ImageProvider.get_icon_url(item.item_id)
655        image = (
656            ImageProvider.create_image(image_url, self.context.provider_domain)
657            if image_url
658            else None
659        )
660        if not item or not item.title:
661            raise ConversionError(f"No menu item {item}")
662        path = self._build_path(item.item_id)
663
664        return_type = BrowseFolder
665
666        if self.context.force_type is RecommendationFolder:
667            return_type = RecommendationFolder
668
669        return return_type(
670            item_id=item.item_id,
671            name=item.title,
672            provider=self.context.provider_domain,
673            path=path,
674            image=image,
675        )
676
677    def _convert_category_or_collection(self, item: Category | Collection) -> BrowseFolder:
678        """Convert Category or Collection to BrowseFolder."""
679        path_prefix = "categories" if isinstance(item, Category) else "collections"
680        path = f"{self.context.provider_domain}://{path_prefix}/{item.item_id}"
681
682        return BrowseFolder(
683            item_id=item.item_id,
684            name=self._get_attr(item, "titles.primary", "Untitled folder"),
685            provider=self.context.provider_domain,
686            path=path,
687            image=(
688                ImageProvider.create_image(item.image_url, self.context.provider_domain)
689                if item.image_url
690                else None
691            ),
692        )
693
694    def _convert_schedule(self, schedule: Schedule) -> BrowseFolder:
695        """Convert Schedule to BrowseFolder."""
696        return BrowseFolder(
697            item_id="schedule",
698            name="Schedule",
699            provider=self.context.provider_domain,
700            path=self._build_path("schedule"),
701        )
702
703    async def _convert_recommended_item(self, item: RecommendedMenuItem) -> RecommendationFolder:
704        """Convert RecommendedMenuItem to RecommendationFolder."""
705        if not item or not item.sub_items or not item.title:
706            raise ConversionError(f"Incorrect format for item {item}")
707
708        # TODO this is messy
709        new_adaptor = Adaptor(provider=self.context.provider)
710        items: list[Track | Radio | MAPodcast | MAPodcastEpisode | BrowseFolder] = []
711        for sub_item in item.sub_items:
712            new_item = await new_adaptor.new_object(sub_item)
713            if (
714                new_item is not None
715                and not isinstance(new_item, RecommendationFolder)
716                and not isinstance(new_item, RecommendedMenuItem)
717            ):
718                items.append(new_item)
719
720        return RecommendationFolder(
721            item_id=item.item_id,
722            name=item.title,
723            provider=self.context.provider_domain,
724            items=UniqueList(items),
725        )
726
727    def _build_path(self, item_id: str) -> str:
728        """Build path for browse items."""
729        if self.context.path_parts:
730            return "/".join([*self.context.path_parts, item_id])
731        return f"{self.context.provider_domain}://{item_id}"
732
733
734class Adaptor:
735    """An adaptor object to convert Sounds API objects into MA ones."""
736
737    def __init__(self, provider: "BBCSoundsProvider"):
738        """Create new adaptor."""
739        self.provider = provider
740        self.logger = self.provider.logger
741        self._converters: list[BaseConverter] = []
742
743    def _create_context(
744        self,
745        path_parts: list[str] | None = None,
746        force_type: (
747            type[Track]
748            | type[Any]
749            | type[Radio]
750            | type[Podcast]
751            | type[PodcastEpisode]
752            | type[BrowseFolder]
753            | type[RecommendationFolder]
754            | None
755        ) = None,
756    ) -> Context:
757        return Context(
758            provider=self.provider,
759            provider_domain=self.provider.domain,
760            path_parts=path_parts,
761            force_type=force_type,
762        )
763
764    async def new_streamable_object(
765        self,
766        source_obj: SoundsTypes,
767        force_type: type[Track] | type[Radio] | type[MAPodcastEpisode] | None = None,
768        path_parts: list[str] | None = None,
769    ) -> StreamDetails | None:
770        """
771        Convert an auntie-sounds object to appropriate Music Assistant object.
772
773        Args:
774            source_obj: The source object from Sounds API via auntie-sounds
775            force_type: Force conversion to specific type if the expected target type is known
776            path_parts: Path parts for browse items to construct the object's path
777
778        Returns:
779            Converted Music Assistant media item or None if no converter found
780        """
781        if source_obj is None:
782            return None
783
784        context = self._create_context(path_parts, force_type)
785
786        converters = [
787            StationConverter(context),
788            PodcastConverter(context),
789            BrowseConverter(context),
790        ]
791
792        for converter in converters:
793            if converter.can_convert(source_obj):
794                try:
795                    stream_details = await converter.get_stream_details(source_obj)
796                    self.provider.logger.debug(
797                        f"Successfully converted {type(source_obj).__name__}"
798                        f" to {type(stream_details).__name__}"
799                    )
800                    return stream_details
801                except Exception as e:
802                    self.provider.logger.error(
803                        f"Unexpected error in converter {type(converter).__name__}: {e}"
804                    )
805                    raise
806        self.provider.logger.warning(
807            f"No stream converter found for type {type(source_obj).__name__}"
808        )
809        return None
810
811    async def new_object(
812        self,
813        source_obj: SoundsTypes,
814        force_type: (
815            type[
816                Track
817                | Radio
818                | MAPodcast
819                | MAPodcastEpisode
820                | BrowseFolder
821                | RecommendationFolder
822                | RecommendedMenuItem
823            ]
824            | None
825        ) = None,
826        path_parts: list[str] | None = None,
827    ) -> (
828        Track
829        | Radio
830        | MAPodcast
831        | MAPodcastEpisode
832        | BrowseFolder
833        | RecommendationFolder
834        | RecommendedMenuItem
835        | None
836    ):
837        """
838        Convert an auntie-sounds object to appropriate Music Assistant object.
839
840        Args:
841            source_obj: The source object from Sounds API via auntie-sounds
842            force_type: Force conversion to specific type if the expected target type is known
843            path_parts: Path parts for browse items to construct the object's path
844
845        Returns:
846            Converted Music Assistant media item or None if no converter found
847        """
848        if source_obj is None:
849            return None
850
851        context = self._create_context(path_parts, force_type)
852
853        converters = [
854            StationConverter(context),
855            PodcastConverter(context),
856            BrowseConverter(context),
857        ]
858        for converter in converters:
859            self.logger.debug(f"Checking if converter {converter} can convert {type(source_obj)}")
860            if converter.can_convert(source_obj):
861                try:
862                    result = await converter.convert(source_obj)
863                    if context.force_type:
864                        assert type(result) is context.force_type, (
865                            f"Forced type to {context.force_type} but received {type(result)} "
866                            f"using {type(converter)}"
867                        )
868                    self.provider.logger.debug(
869                        f"Successfully converted {type(source_obj).__name__}"
870                        f" to {type(result).__name__} {result}"
871                    )
872                    return result
873                except Exception as e:
874                    self.provider.logger.error(
875                        f"Unexpected error in converter {type(converter).__name__}: {e}"
876                    )
877                    raise
878            self.logger.debug(f"Converter {converter} could not convert {type(source_obj)}")
879
880        self.logger.warning(f"No converter found for type {type(source_obj).__name__}")
881        return None
882