Add support for iBroadcast as music provider (#1799)
authorRob Sonke <r.sonke@maxxton.com>
Mon, 23 Dec 2024 09:13:03 +0000 (10:13 +0100)
committerGitHub <noreply@github.com>
Mon, 23 Dec 2024 09:13:03 +0000 (10:13 +0100)
music_assistant/providers/ibroadcast/__init__.py [new file with mode: 0644]
music_assistant/providers/ibroadcast/icon.svg [new file with mode: 0644]
music_assistant/providers/ibroadcast/manifest.json [new file with mode: 0644]
requirements_all.txt

diff --git a/music_assistant/providers/ibroadcast/__init__.py b/music_assistant/providers/ibroadcast/__init__.py
new file mode 100644 (file)
index 0000000..7fe47eb
--- /dev/null
@@ -0,0 +1,443 @@
+"""iBroadcast support for MusicAssistant."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from aiohttp import ClientSession
+from ibroadcastaio import IBroadcastClient
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant_models.enums import (
+    AlbumType,
+    ConfigEntryType,
+    ContentType,
+    ImageType,
+    ProviderFeature,
+    StreamType,
+)
+from music_assistant_models.errors import InvalidDataError, LoginFailed
+from music_assistant_models.media_items import (
+    Album,
+    Artist,
+    AudioFormat,
+    ItemMapping,
+    MediaItemImage,
+    MediaType,
+    Playlist,
+    ProviderMapping,
+    Track,
+)
+from music_assistant_models.streamdetails import StreamDetails
+
+from music_assistant.constants import (
+    CONF_PASSWORD,
+    CONF_USERNAME,
+    UNKNOWN_ARTIST,
+    VARIOUS_ARTISTS_MBID,
+    VARIOUS_ARTISTS_NAME,
+)
+from music_assistant.helpers.util import parse_title_and_version
+from music_assistant.models.music_provider import MusicProvider
+
+SUPPORTED_FEATURES = (
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.BROWSE,
+    ProviderFeature.ARTIST_ALBUMS,
+)
+
+
+if TYPE_CHECKING:
+    from collections.abc import AsyncGenerator
+
+    from music_assistant_models.config_entries import ProviderConfig
+
+    from music_assistant import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+    from music_assistant.models.provider import ProviderManifest
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    if not config.get_value(CONF_USERNAME) or not config.get_value(CONF_PASSWORD):
+        msg = "Invalid login credentials"
+        raise LoginFailed(msg)
+    return IBroadcastProvider(mass, manifest, config)
+
+
+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=CONF_USERNAME,
+            type=ConfigEntryType.STRING,
+            label="Username",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_PASSWORD,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Password",
+            required=True,
+        ),
+    )
+
+
+class IBroadcastProvider(MusicProvider):
+    """Provider for iBroadcast."""
+
+    _user_id = None
+    _client = None
+    _token = None
+
+    async def handle_async_init(self) -> None:
+        """Set up the iBroadcast provider."""
+        async with ClientSession() as session:
+            self._client = IBroadcastClient(session)
+            status = await self._client.login(
+                self.config.get_value(CONF_USERNAME), self.config.get_value(CONF_PASSWORD)
+            )
+            self._user_id = status["user"]["id"]
+            self._token = status["user"]["token"]
+
+            # temporary call to refresh library until ibroadcast provides a detailed api
+            await self._client.refresh_library()
+
+    @property
+    def supported_features(self) -> tuple[ProviderFeature, ...]:
+        """Return the features supported by this Provider."""
+        return SUPPORTED_FEATURES
+
+    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+        """Retrieve library albums from ibroadcast."""
+        for album in (await self._client.get_albums()).values():
+            try:
+                yield await self._parse_album(album)
+            except (KeyError, TypeError, InvalidDataError, IndexError) as error:
+                self.logger.debug("Parse album failed: %s", album, exc_info=error)
+                continue
+
+    async def get_album(self, prov_album_id: str) -> Album:
+        """Get full album details by id."""
+        album_obj = await self._client.get_album(int(prov_album_id))
+        return await self._parse_album(album_obj)
+
+    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+        """Retrieve all library artists from iBroadcast."""
+        for artist in (await self._client.get_artists()).values():
+            try:
+                yield await self._parse_artist(artist)
+            except (KeyError, TypeError, InvalidDataError, IndexError) as error:
+                self.logger.debug("Parse artist failed: %s", artist, exc_info=error)
+                continue
+
+    async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
+        """Get a list of albums for the given artist."""
+        albums_objs = [
+            album
+            for album in (await self._client.get_albums()).values()
+            if album["artist_id"] == int(prov_artist_id)
+        ]
+        albums = []
+        for album in albums_objs:
+            try:
+                albums.append(self._parse_album(album))
+            except (KeyError, TypeError, InvalidDataError, IndexError) as error:
+                self.logger.debug("Parse album failed: %s", album, exc_info=error)
+                continue
+        return albums
+
+    async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
+        """Get album tracks for given album id."""
+        album = await self._client.get_album(int(prov_album_id))
+        return await self._get_tracks(album["tracks"])
+
+    async def get_track(self, prov_track_id: str) -> Track:
+        """Get full track details by id."""
+        track_obj = await self._client.get_track(int(prov_track_id))
+        return await self._parse_track(track_obj)
+
+    async def get_artist(self, prov_artist_id: str) -> Artist:
+        """Get full artist details by id."""
+        artist_obj = await self._client.get_artist(int(prov_artist_id))
+        return await self._parse_artist(artist_obj)
+
+    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
+        """Retrieve library tracks from iBroadcast."""
+        for track in (await self._client.get_tracks()).values():
+            try:
+                yield await self._parse_track(track)
+            except IndexError:
+                continue
+            except (KeyError, TypeError, InvalidDataError) as error:
+                self.logger.debug("Parse track failed: %s", track, exc_info=error)
+                continue
+
+    def _get_artist_item_mapping(self, artist_id, artist_obj: dict) -> ItemMapping:
+        if (not artist_id and artist_obj["name"] == "Various Artists") or artist_id == 0:
+            artist_id = VARIOUS_ARTISTS_MBID
+        return self._get_item_mapping(MediaType.ARTIST, artist_id, artist_obj.get("name"))
+
+    def _get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping:
+        return ItemMapping(
+            media_type=media_type,
+            item_id=key,
+            provider=self.instance_id,
+            name=name,
+        )
+
+    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+        """Retrieve playlists from iBroadcast."""
+        for playlist in (await self._client.get_playlists()).values():
+            # Skip the auto generated playlist
+            if playlist["type"] != "recently-played" and playlist["type"] != "thumbsup":
+                yield await self._parse_playlist(playlist)
+
+    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
+        """Get full playlist details by id."""
+        playlist_obj = await self._client.get_playlist(int(prov_playlist_id))
+        try:
+            playlist = await self._parse_playlist(playlist_obj)
+        except (KeyError, TypeError, InvalidDataError, IndexError) as error:
+            self.logger.debug("Parse playlist failed: %s", playlist_obj, exc_info=error)
+        return playlist
+
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
+        """Get playlist tracks."""
+        tracks: list[Track] = []
+        if page > 0:
+            return tracks
+        playlist_obj = await self._client.get_playlist(int(prov_playlist_id))
+        if "tracks" not in playlist_obj:
+            return tracks
+        return await self._get_tracks(playlist_obj["tracks"], True)
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Return the content details for the given track when it will be streamed."""
+        # How to buildup a stream url:
+        # [streaming_server]/[url]?Expires=[now]&Signature=[user token]&file_id=[file ID]
+        # &user_id=[user ID]&platform=[your app name]&version=[your app version]
+        # See https://devguide.ibroadcast.com/?p=streaming-server
+        url = await self._client.get_full_stream_url(int(item_id), "music-assistant")
+
+        return StreamDetails(
+            provider=self.instance_id,
+            item_id=item_id,
+            audio_format=AudioFormat(
+                content_type=ContentType.UNKNOWN,
+            ),
+            stream_type=StreamType.HTTP,
+            path=url,
+        )
+
+    async def _get_tracks(self, track_ids: list[int], is_playlist: bool = False) -> list[Track]:
+        """Retrieve a list of tracks based on provided track IDs."""
+        tracks = []
+        for index, track_id in enumerate(track_ids, 1):
+            track_obj = await self._client.get_track(track_id)
+            if track_obj is not None:
+                track = await self._parse_track(track_obj)
+                if is_playlist:
+                    track.position = index
+                tracks.append(track)
+        return tracks
+
+    async def _parse_artist(self, artist_obj: dict) -> Artist:
+        """Parse a iBroadcast user response to Artist model object."""
+        artist_id = artist_obj["artist_id"]
+        artist = Artist(
+            item_id=artist_id,
+            name=artist_obj["name"],
+            provider=self.domain,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=artist_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=f"https://media.ibroadcast.com/?view=container&container_id={artist_id}&type=artists",
+                )
+            },
+        )
+        # Artwork
+        if "artwork_id" in artist_obj:
+            artist.metadata.images = [
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=await self._client.get_artist_artwork_url(artist_id),
+                    provider=self.instance_id,
+                    remotely_accessible=True,
+                )
+            ]
+        return artist
+
+    async def _parse_album(self, album_obj: dict) -> Album:
+        """Parse ibroadcast album object to generic layout."""
+        album_id = album_obj["album_id"]
+        name, version = parse_title_and_version(album_obj["name"])
+        album = Album(
+            item_id=album_id,
+            provider=self.domain,
+            name=name,
+            year=album_obj["year"],
+            version=version,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=album_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(content_type=ContentType.MPEG),
+                    url=f"https://media.ibroadcast.com/?view=container&container_id={album_id}&type=albums",
+                )
+            },
+        )
+        artist = None
+        if album_obj["artist_id"] == 0:
+            artist = Artist(
+                item_id=VARIOUS_ARTISTS_MBID,
+                name=VARIOUS_ARTISTS_NAME,
+                provider=self.instance_id,
+                provider_mappings={
+                    ProviderMapping(
+                        item_id=VARIOUS_ARTISTS_MBID,
+                        provider_domain=self.domain,
+                        provider_instance=self.instance_id,
+                    )
+                },
+            )
+        else:
+            artist = self._get_item_mapping(
+                MediaType.ARTIST,
+                album_obj["artist_id"],
+                (await self._client.get_artist(album_obj["artist_id"]))["name"]
+                if await self._client.get_artist(album_obj["artist_id"])
+                else UNKNOWN_ARTIST,
+            )
+        album.artists.append(artist)
+
+        if "rating" in album_obj and album_obj["rating"] == 5:
+            album.favorite = True
+        # iBroadcast doesn't seem to know album type
+        album.album_type = AlbumType.UNKNOWN
+        # There is only an artwork in the tracks, lets get the first track one
+        artwork_url = await self._client.get_album_artwork_url(album_id)
+        if artwork_url:
+            album.metadata.images = [self._get_artwork_object(artwork_url)]
+        return album
+
+    def _get_artwork_object(self, url: str) -> MediaItemImage:
+        return MediaItemImage(
+            type=ImageType.THUMB,
+            path=url,
+            provider=self.instance_id,
+            remotely_accessible=True,
+        )
+
+    async def _parse_track(self, track_obj: dict) -> Track:
+        """Parse an iBroadcast track object to a Track model object."""
+        track = Track(
+            item_id=track_obj["track_id"],
+            provider=self.domain,
+            name=track_obj["title"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=track_obj["track_id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    available=not track_obj["trashed"],
+                    audio_format=AudioFormat(
+                        content_type=ContentType.MPEG,
+                    ),
+                )
+            },
+        )
+        if "rating" in track_obj and track_obj["rating"] == 5:
+            track.favorite = True
+        if "length" in track_obj and str(track_obj["length"]).isdigit():
+            track.duration = track_obj["length"]
+        # track number looks like 201, meaning, disc 2, track 1
+        if track_obj["track"] > 99:
+            track.disc_number = int(str(track_obj["track"])[:1])
+            track.track_number = int(str(track_obj["track"])[1:])
+        else:
+            track.track_number = int(track_obj["track"])
+        # Track artists
+        if "artist_id" in track_obj:
+            artist_id = track_obj["artist_id"]
+            track.artists = [
+                self._get_artist_item_mapping(artist_id, await self._client.get_artist(artist_id))
+            ]
+            # additional artists structure: 'artists_additional': [[artist id, phrase, type]]
+            track.artists.extend(
+                [
+                    self._get_artist_item_mapping(
+                        additional_artist[0], await self._client.get_artist(additional_artist[0])
+                    )
+                    for additional_artist in track_obj["artists_additional"]
+                    if additional_artist[0]
+                ]
+            )
+            # guard that track has valid artists
+            if not track.artists:
+                msg = "Track is missing artists"
+                raise InvalidDataError(msg)
+
+        # Artwork
+        track.metadata.images = [
+            self._get_artwork_object(
+                await self._client.get_track_artwork_url(track_obj["track_id"])
+            )
+        ]
+        # Genre
+        genres = []
+        if track_obj["genre"]:
+            genres = [track_obj["genre"]]
+        if track_obj["genres_additional"]:
+            genres.extend(track_obj["genres_additional"])
+        track.metadata.genres = genres
+        if track_obj["album_id"]:
+            album = await self._client.get_album(track_obj["album_id"])
+            if album:
+                track.album = self._get_item_mapping(
+                    MediaType.ALBUM, track_obj["album_id"], album["name"]
+                )
+        return track
+
+    async def _parse_playlist(self, playlist_obj: dict) -> Playlist:
+        """Parse an iBroadcast Playlist response to a Playlist object."""
+        playlist_id = str(playlist_obj["playlist_id"])
+        playlist = Playlist(
+            item_id=playlist_id,
+            provider=self.domain,
+            name=playlist_obj["name"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=playlist_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
+        )
+        # Can be supported in future, the API has options available
+        playlist.is_editable = False
+        playlist.metadata.images = [
+            self._get_artwork_object(await self._client.get_playlist_artwork_url(int(playlist_id)))
+        ]
+        if "description" in playlist_obj:
+            playlist.metadata.description = playlist_obj["description"]
+        return playlist
diff --git a/music_assistant/providers/ibroadcast/icon.svg b/music_assistant/providers/ibroadcast/icon.svg
new file mode 100644 (file)
index 0000000..9e9cf97
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" version="1.1">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(12.156863%,7.843137%,5.882353%);fill-opacity:1;" d="M 0 0 C 13.199219 0 26.398438 0 40 0 C 40 13.199219 40 26.398438 40 40 C 26.800781 40 13.601562 40 0 40 C 0 26.800781 0 13.601562 0 0 Z M 0 0 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(81.176471%,81.960784%,81.960784%);fill-opacity:1;" d="M 16.300781 8.300781 C 28.957031 8.300781 28.957031 8.300781 31 10.101562 C 31.058594 10.144531 31.117188 10.191406 31.175781 10.234375 C 31.914062 10.875 32.53125 12.082031 32.683594 13.046875 C 32.796875 14.652344 32.660156 16.140625 31.699219 17.5 C 31.242188 18.019531 30.746094 18.472656 30.199219 18.898438 C 30.289062 18.9375 30.375 18.976562 30.46875 19.019531 C 31.875 19.664062 32.964844 20.589844 33.53125 22.0625 C 34.136719 23.761719 33.878906 25.832031 33.125 27.445312 C 32.21875 29.089844 30.761719 30.183594 29 30.800781 C 25.082031 31.898438 19.320312 31.199219 16.300781 31.199219 C 16.300781 23.644531 16.300781 16.085938 16.300781 8.300781 Z M 16.300781 8.300781 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(81.568627%,82.352941%,82.352941%);fill-opacity:1;" d="M 7.601562 18.398438 C 9.316406 18.398438 11.03125 18.398438 12.800781 18.398438 C 12.800781 22.625 12.800781 26.847656 12.800781 31.199219 C 11.085938 31.199219 9.367188 31.199219 7.601562 31.199219 C 7.601562 26.976562 7.601562 22.753906 7.601562 18.398438 Z M 7.601562 18.398438 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(44.705882%,44.313725%,46.27451%);fill-opacity:1;" d="M 11.675781 10.5625 C 12.46875 11.050781 12.910156 11.734375 13.199219 12.601562 C 13.328125 13.628906 13.058594 14.394531 12.4375 15.21875 C 11.867188 15.839844 11.257812 16.109375 10.417969 16.152344 C 9.644531 16.167969 9.046875 16.039062 8.398438 15.601562 C 8.308594 15.539062 8.214844 15.480469 8.117188 15.417969 C 7.503906 14.804688 7.175781 14.058594 7.148438 13.1875 C 7.164062 12.375 7.445312 11.730469 7.960938 11.105469 C 8.941406 10.175781 10.460938 9.996094 11.675781 10.5625 Z M 11.675781 10.5625 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(12.54902%,8.235294%,6.27451%);fill-opacity:1;" d="M 22.300781 21.898438 C 22.9375 21.890625 23.574219 21.882812 24.230469 21.875 C 24.429688 21.871094 24.628906 21.867188 24.832031 21.863281 C 25.890625 21.851562 26.84375 21.855469 27.707031 22.542969 C 28.125 23.097656 28.179688 23.617188 28.101562 24.300781 C 27.925781 24.925781 27.65625 25.347656 27.101562 25.699219 C 26.171875 26.136719 25.132812 26.03125 24.136719 26.019531 C 23.53125 26.011719 22.925781 26.007812 22.300781 26 C 22.300781 24.648438 22.300781 23.292969 22.300781 21.898438 Z M 22.300781 21.898438 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(13.72549%,9.411765%,7.45098%);fill-opacity:1;" d="M 22.300781 13.398438 C 23.0625 13.382812 23.0625 13.382812 23.835938 13.367188 C 23.996094 13.363281 24.152344 13.359375 24.316406 13.355469 C 25.289062 13.34375 26.042969 13.453125 26.761719 14.15625 C 27.011719 14.777344 27 15.421875 26.773438 16.050781 C 26.398438 16.585938 26.070312 16.828125 25.425781 16.953125 C 25.089844 17 24.769531 17.011719 24.429688 17.007812 C 24.316406 17.007812 24.203125 17.007812 24.089844 17.007812 C 23.949219 17.007812 23.808594 17.007812 23.664062 17.007812 C 22.988281 17.003906 22.988281 17.003906 22.300781 17 C 22.300781 15.8125 22.300781 14.625 22.300781 13.398438 Z M 22.300781 13.398438 "/>
+</g>
+</svg>
diff --git a/music_assistant/providers/ibroadcast/manifest.json b/music_assistant/providers/ibroadcast/manifest.json
new file mode 100644 (file)
index 0000000..9947679
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "type": "music",
+  "domain": "ibroadcast",
+  "name": "iBroadcast",
+  "description": "Support for the iBroadcast streaming provider in Music Assistant.",
+  "codeowners": ["@robsonke"],
+  "requirements": ["ibroadcastaio==0.3.1"],
+  "documentation": "https://music-assistant.io/music-providers/ibroadcast/",
+  "multi_instance": true
+}
index 60fb03c7c38bbf186701a6134b9eb46870c6aace..1d3a3ebdbe65d4507035be261237a703cd302172 100644 (file)
@@ -19,6 +19,7 @@ defusedxml==0.7.1
 eyeD3==0.9.7
 faust-cchardet>=2.1.18
 hass-client==1.2.0
+ibroadcastaio==0.3.1
 ifaddr==0.2.0
 mashumaro==3.14
 memory-tempfile==2.2.3