add base foundation for podcasts and audiobooks
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 22 Nov 2024 00:37:35 +0000 (01:37 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 22 Nov 2024 15:45:28 +0000 (16:45 +0100)
24 files changed:
music_assistant/constants.py
music_assistant/controllers/media/albums.py
music_assistant/controllers/media/audiobooks.py [new file with mode: 0644]
music_assistant/controllers/media/podcasts.py [new file with mode: 0644]
music_assistant/controllers/music.py
music_assistant/helpers/compare.py
music_assistant/models/music_provider.py
music_assistant/providers/_template_music_provider/__init__.py
music_assistant/providers/apple_music/__init__.py
music_assistant/providers/builtin/__init__.py
music_assistant/providers/deezer/__init__.py
music_assistant/providers/filesystem_local/__init__.py
music_assistant/providers/jellyfin/__init__.py
music_assistant/providers/opensubsonic/sonic_provider.py
music_assistant/providers/plex/__init__.py
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/radiobrowser/__init__.py
music_assistant/providers/siriusxm/__init__.py
music_assistant/providers/soundcloud/__init__.py
music_assistant/providers/spotify/__init__.py
music_assistant/providers/test/__init__.py
music_assistant/providers/tidal/__init__.py
music_assistant/providers/tunein/__init__.py
music_assistant/providers/ytmusic/__init__.py

index acaebbc2ab2a1b114473d4263d62c642236f44bd..ae8565aaaa06b815aaaac192b4a315e11212f948 100644 (file)
@@ -83,6 +83,8 @@ DB_TABLE_ALBUMS: Final[str] = "albums"
 DB_TABLE_TRACKS: Final[str] = "tracks"
 DB_TABLE_PLAYLISTS: Final[str] = "playlists"
 DB_TABLE_RADIOS: Final[str] = "radios"
+DB_TABLE_AUDIOBOOKS: Final[str] = "audiobooks"
+DB_TABLE_PODCASTS: Final[str] = "podcasts"
 DB_TABLE_CACHE: Final[str] = "cache"
 DB_TABLE_SETTINGS: Final[str] = "settings"
 DB_TABLE_THUMBS: Final[str] = "thumbnails"
index fef594c0b304d1f13f130cfdc3a322b9d91b4d51..626a0f1044ca9267e8b7132e8c6435238bf44deb 100644 (file)
@@ -377,9 +377,12 @@ class AlbumsController(MediaControllerBase[Album]):
         # store (serializable items) in cache
         if prov.is_streaming_provider:
             self.mass.create_task(
-                self.mass.cache.set(cache_key, [x.to_dict() for x in items]),
-                category=cache_category,
-                base_key=cache_base_key,
+                self.mass.cache.set(
+                    cache_key,
+                    [x.to_dict() for x in items],
+                    category=cache_category,
+                    base_key=cache_base_key,
+                ),
             )
         for item in items:
             # if this is a complete track object, pre-cache it as
diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py
new file mode 100644 (file)
index 0000000..b7530a8
--- /dev/null
@@ -0,0 +1,305 @@
+"""Manage MediaItems of type Audiobook."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from music_assistant_models.enums import MediaType, ProviderFeature
+from music_assistant_models.errors import InvalidDataError
+from music_assistant_models.media_items import Artist, Audiobook, Chapter, UniqueList
+
+from music_assistant.constants import DB_TABLE_AUDIOBOOKS
+from music_assistant.controllers.media.base import MediaControllerBase
+from music_assistant.helpers.compare import (
+    compare_audiobook,
+    compare_media_item,
+    loose_compare_strings,
+)
+from music_assistant.helpers.json import serialize_to_json
+
+if TYPE_CHECKING:
+    from music_assistant_models.media_items import Track
+
+    from music_assistant.models.music_provider import MusicProvider
+
+
+class AudiobooksController(MediaControllerBase[Audiobook]):
+    """Controller managing MediaItems of type Audiobook."""
+
+    db_table = DB_TABLE_AUDIOBOOKS
+    media_type = MediaType.AUDIOBOOK
+    item_cls = Audiobook
+
+    def __init__(self, *args, **kwargs) -> None:
+        """Initialize class."""
+        super().__init__(*args, **kwargs)
+        self.base_query = """
+        SELECT
+            audiobooks.*,
+            (SELECT JSON_GROUP_ARRAY(
+                json_object(
+                'item_id', provider_mappings.provider_item_id,
+                    'provider_domain', provider_mappings.provider_domain,
+                        'provider_instance', provider_mappings.provider_instance,
+                        'available', provider_mappings.available,
+                        'audio_format', json(provider_mappings.audio_format),
+                        'url', provider_mappings.url,
+                        'details', provider_mappings.details
+                )) FROM provider_mappings WHERE provider_mappings.item_id = audiobooks.item_id AND media_type = 'audiobook') AS provider_mappings
+            FROM audiobooks"""  # noqa: E501
+        # register (extra) api handlers
+        api_base = self.api_base
+        self.mass.register_api_command(f"music/{api_base}/audiobook_chapters", self.chapters)
+        self.mass.register_api_command(f"music/{api_base}/audiobook_versions", self.versions)
+
+    async def library_items(
+        self,
+        favorite: bool | None = None,
+        search: str | None = None,
+        limit: int = 500,
+        offset: int = 0,
+        order_by: str = "sort_name",
+        provider: str | None = None,
+        extra_query: str | None = None,
+        extra_query_params: dict[str, Any] | None = None,
+    ) -> list[Artist]:
+        """Get in-database audiobooks."""
+        extra_query_params: dict[str, Any] = extra_query_params or {}
+        extra_query_parts: list[str] = [extra_query] if extra_query else []
+        result = await self._get_library_items_by_query(
+            favorite=favorite,
+            search=search,
+            limit=limit,
+            offset=offset,
+            order_by=order_by,
+            provider=provider,
+            extra_query_parts=extra_query_parts,
+            extra_query_params=extra_query_params,
+        )
+        if search and len(result) < 25 and not offset:
+            # append author items to result
+            extra_query_parts = [
+                "WHERE audiobooks.authors LIKE :search OR audiobooks.name LIKE :search",
+            ]
+            extra_query_params["search"] = f"%{search}%"
+            return result + await self._get_library_items_by_query(
+                favorite=favorite,
+                search=None,
+                limit=limit,
+                order_by=order_by,
+                provider=provider,
+                extra_query_parts=extra_query_parts,
+                extra_query_params=extra_query_params,
+            )
+        return result
+
+    async def chapters(
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str,
+    ) -> UniqueList[Chapter]:
+        """Return audiobook chapters for the given provider audiobook id."""
+        # always check if we have a library item for this audiobook
+        library_audiobook = await self.get_library_item_by_prov_id(
+            item_id, provider_instance_id_or_domain
+        )
+        if not library_audiobook:
+            return await self._get_provider_audiobook_chapters(
+                item_id, provider_instance_id_or_domain
+            )
+        # return items from first/only provider
+        for provider_mapping in library_audiobook.provider_mappings:
+            return await self._get_provider_audiobook_chapters(
+                provider_mapping.item_id, provider_mapping.provider_instance
+            )
+        return UniqueList()
+
+    async def versions(
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str,
+    ) -> UniqueList[Audiobook]:
+        """Return all versions of an audiobook we can find on all providers."""
+        audiobook = await self.get_provider_item(item_id, provider_instance_id_or_domain)
+        search_query = audiobook.name
+        result: UniqueList[Audiobook] = UniqueList()
+        for provider_id in self.mass.music.get_unique_providers():
+            provider = self.mass.get_provider(provider_id)
+            if not provider:
+                continue
+            if not provider.library_supported(MediaType.AUDIOBOOK):
+                continue
+            result.extend(
+                prov_item
+                for prov_item in await self.search(search_query, provider_id)
+                if loose_compare_strings(audiobook.name, prov_item.name)
+                # make sure that the 'base' version is NOT included
+                and not audiobook.provider_mappings.intersection(prov_item.provider_mappings)
+            )
+        return result
+
+    async def _add_library_item(self, item: Audiobook) -> int:
+        """Add a new record to the database."""
+        if not isinstance(item, Audiobook):
+            msg = "Not a valid Audiobook object (ItemMapping can not be added to db)"
+            raise InvalidDataError(msg)
+        db_id = await self.mass.music.database.insert(
+            self.db_table,
+            {
+                "name": item.name,
+                "sort_name": item.sort_name,
+                "version": item.version,
+                "favorite": item.favorite,
+                "metadata": serialize_to_json(item.metadata),
+                "external_ids": serialize_to_json(item.external_ids),
+                "publisher": item.publisher,
+                "total_chapters": item.total_chapters,
+                "authors": item.authors,
+                "narrators": item.narrators,
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, item.provider_mappings)
+        self.logger.debug("added %s to database (id: %s)", item.name, db_id)
+        return db_id
+
+    async def _update_library_item(
+        self, item_id: str | int, update: Audiobook, overwrite: bool = False
+    ) -> None:
+        """Update existing record in the database."""
+        db_id = int(item_id)  # ensure integer
+        cur_item = await self.get_library_item(db_id)
+        metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata)
+        cur_item.external_ids.update(update.external_ids)
+        provider_mappings = (
+            update.provider_mappings
+            if overwrite
+            else {*cur_item.provider_mappings, *update.provider_mappings}
+        )
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {
+                "name": update.name if overwrite else cur_item.name,
+                "sort_name": update.sort_name
+                if overwrite
+                else cur_item.sort_name or update.sort_name,
+                "version": update.version if overwrite else cur_item.version or update.version,
+                "metadata": serialize_to_json(metadata),
+                "external_ids": serialize_to_json(
+                    update.external_ids if overwrite else cur_item.external_ids
+                ),
+                "publisher": cur_item.publisher or update.publisher,
+                "total_chapters": cur_item.total_chapters or update.total_chapters,
+                "authors": update.authors if overwrite else cur_item.authors or update.authors,
+                "narrators": update.narrators
+                if overwrite
+                else cur_item.narrators or update.narrators,
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
+
+    async def _get_provider_audiobook_chapters(
+        self, item_id: str, provider_instance_id_or_domain: str
+    ) -> list[Chapter]:
+        """Return audiobook chapters for the given provider audiobook id."""
+        prov: MusicProvider = self.mass.get_provider(provider_instance_id_or_domain)
+        if prov is None:
+            return []
+        # prefer cache items (if any) - for streaming providers only
+        cache_base_key = prov.lookup_key
+        cache_key = f"audiobook.{item_id}"
+        if (
+            prov.is_streaming_provider
+            and (cache := await self.mass.cache.get(cache_key, base_key=cache_base_key)) is not None
+        ):
+            return [Chapter.from_dict(x) for x in cache]
+        # no items in cache - get listing from provider
+        items = await prov.get_audiobook_chapters(item_id)
+        # store (serializable items) in cache
+        if prov.is_streaming_provider:
+            self.mass.create_task(
+                self.mass.cache.set(
+                    cache_key,
+                    [x.to_dict() for x in items],
+                    expiration=3600,
+                    base_key=cache_base_key,
+                ),
+            )
+
+        return items
+
+    async def _get_provider_dynamic_base_tracks(
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str,
+        limit: int = 25,
+    ) -> list[Track]:
+        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
+        msg = "Dynamic tracks not supported for Radio MediaItem"
+        raise NotImplementedError(msg)
+
+    async def _get_dynamic_tracks(self, media_item: Audiobook, limit: int = 25) -> list[Track]:
+        """Get dynamic list of tracks for given item, fallback/default implementation."""
+        msg = "Dynamic tracks not supported for Audiobook MediaItem"
+        raise NotImplementedError(msg)
+
+    async def match_providers(self, db_audiobook: Audiobook) -> None:
+        """Try to find match on all (streaming) providers for the provided (database) audiobook.
+
+        This is used to link objects of different providers/qualities together.
+        """
+        if db_audiobook.provider != "library":
+            return  # Matching only supported for database items
+        if not db_audiobook.authors:
+            return  # guard
+        author_name = db_audiobook.authors[0]
+
+        async def find_prov_match(provider: MusicProvider):
+            self.logger.debug(
+                "Trying to match audiobook %s on provider %s", db_audiobook.name, provider.name
+            )
+            match_found = False
+            search_str = f"{author_name} - {db_audiobook.name}"
+            search_result = await self.search(search_str, provider.instance_id)
+            for search_result_item in search_result:
+                if not search_result_item.available:
+                    continue
+                if not compare_media_item(db_audiobook, search_result_item):
+                    continue
+                # we must fetch the full audiobook version, search results can be simplified objects
+                prov_audiobook = await self.get_provider_item(
+                    search_result_item.item_id,
+                    search_result_item.provider,
+                    fallback=search_result_item,
+                )
+                if compare_audiobook(db_audiobook, prov_audiobook):
+                    # 100% match, we update the db with the additional provider mapping(s)
+                    match_found = True
+                    for provider_mapping in search_result_item.provider_mappings:
+                        await self.add_provider_mapping(db_audiobook.item_id, provider_mapping)
+                        db_audiobook.provider_mappings.add(provider_mapping)
+            return match_found
+
+        # try to find match on all providers
+        cur_provider_domains = {x.provider_domain for x in db_audiobook.provider_mappings}
+        for provider in self.mass.music.providers:
+            if provider.domain in cur_provider_domains:
+                continue
+            if ProviderFeature.SEARCH not in provider.supported_features:
+                continue
+            if not provider.library_supported(MediaType.AUDIOBOOK):
+                continue
+            if not provider.is_streaming_provider:
+                # matching on unique providers is pointless as they push (all) their content to MA
+                continue
+            if await find_prov_match(provider):
+                cur_provider_domains.add(provider.domain)
+            else:
+                self.logger.debug(
+                    "Could not find match for Audiobook %s on provider %s",
+                    db_audiobook.name,
+                    provider.name,
+                )
diff --git a/music_assistant/controllers/media/podcasts.py b/music_assistant/controllers/media/podcasts.py
new file mode 100644 (file)
index 0000000..d35bd76
--- /dev/null
@@ -0,0 +1,296 @@
+"""Manage MediaItems of type Podcast."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from music_assistant_models.enums import MediaType, ProviderFeature
+from music_assistant_models.errors import InvalidDataError
+from music_assistant_models.media_items import Artist, Episode, Podcast, UniqueList
+
+from music_assistant.constants import DB_TABLE_PODCASTS
+from music_assistant.controllers.media.base import MediaControllerBase
+from music_assistant.helpers.compare import (
+    compare_media_item,
+    compare_podcast,
+    loose_compare_strings,
+)
+from music_assistant.helpers.json import serialize_to_json
+
+if TYPE_CHECKING:
+    from music_assistant_models.media_items import Track
+
+    from music_assistant.models.music_provider import MusicProvider
+
+
+class PodcastsController(MediaControllerBase[Podcast]):
+    """Controller managing MediaItems of type Podcast."""
+
+    db_table = DB_TABLE_PODCASTS
+    media_type = MediaType.PODCAST
+    item_cls = Podcast
+
+    def __init__(self, *args, **kwargs) -> None:
+        """Initialize class."""
+        super().__init__(*args, **kwargs)
+        self.base_query = """
+        SELECT
+            podcasts.*,
+            (SELECT JSON_GROUP_ARRAY(
+                json_object(
+                'item_id', provider_mappings.provider_item_id,
+                    'provider_domain', provider_mappings.provider_domain,
+                        'provider_instance', provider_mappings.provider_instance,
+                        'available', provider_mappings.available,
+                        'audio_format', json(provider_mappings.audio_format),
+                        'url', provider_mappings.url,
+                        'details', provider_mappings.details
+                )) FROM provider_mappings WHERE provider_mappings.item_id = podcasts.item_id AND media_type = 'podcast') AS provider_mappings
+            FROM podcasts"""  # noqa: E501
+        # register (extra) api handlers
+        api_base = self.api_base
+        self.mass.register_api_command(f"music/{api_base}/podcast_episodes", self.episodes)
+        self.mass.register_api_command(f"music/{api_base}/podcast_versions", self.versions)
+
+    async def library_items(
+        self,
+        favorite: bool | None = None,
+        search: str | None = None,
+        limit: int = 500,
+        offset: int = 0,
+        order_by: str = "sort_name",
+        provider: str | None = None,
+        extra_query: str | None = None,
+        extra_query_params: dict[str, Any] | None = None,
+    ) -> list[Artist]:
+        """Get in-database podcasts."""
+        extra_query_params: dict[str, Any] = extra_query_params or {}
+        extra_query_parts: list[str] = [extra_query] if extra_query else []
+        result = await self._get_library_items_by_query(
+            favorite=favorite,
+            search=search,
+            limit=limit,
+            offset=offset,
+            order_by=order_by,
+            provider=provider,
+            extra_query_parts=extra_query_parts,
+            extra_query_params=extra_query_params,
+        )
+        if search and len(result) < 25 and not offset:
+            # append publisher items to result
+            extra_query_parts = [
+                "WHERE podcasts.publisher LIKE :search OR podcasts.name LIKE :search",
+            ]
+            extra_query_params["search"] = f"%{search}%"
+            return result + await self._get_library_items_by_query(
+                favorite=favorite,
+                search=None,
+                limit=limit,
+                order_by=order_by,
+                provider=provider,
+                extra_query_parts=extra_query_parts,
+                extra_query_params=extra_query_params,
+            )
+        return result
+
+    async def episodes(
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str,
+    ) -> UniqueList[Episode]:
+        """Return podcast episodes for the given provider podcast id."""
+        # always check if we have a library item for this podcast
+        library_podcast = await self.get_library_item_by_prov_id(
+            item_id, provider_instance_id_or_domain
+        )
+        if not library_podcast:
+            return await self._get_provider_podcast_episodes(
+                item_id, provider_instance_id_or_domain
+            )
+        # return items from first/only provider
+        for provider_mapping in library_podcast.provider_mappings:
+            return await self._get_provider_podcast_episodes(
+                provider_mapping.item_id, provider_mapping.provider_instance
+            )
+        return UniqueList()
+
+    async def versions(
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str,
+    ) -> UniqueList[Podcast]:
+        """Return all versions of an podcast we can find on all providers."""
+        podcast = await self.get_provider_item(item_id, provider_instance_id_or_domain)
+        search_query = podcast.name
+        result: UniqueList[Podcast] = UniqueList()
+        for provider_id in self.mass.music.get_unique_providers():
+            provider = self.mass.get_provider(provider_id)
+            if not provider:
+                continue
+            if not provider.library_supported(MediaType.PODCAST):
+                continue
+            result.extend(
+                prov_item
+                for prov_item in await self.search(search_query, provider_id)
+                if loose_compare_strings(podcast.name, prov_item.name)
+                # make sure that the 'base' version is NOT included
+                and not podcast.provider_mappings.intersection(prov_item.provider_mappings)
+            )
+        return result
+
+    async def _add_library_item(self, item: Podcast) -> int:
+        """Add a new record to the database."""
+        if not isinstance(item, Podcast):
+            msg = "Not a valid Podcast object (ItemMapping can not be added to db)"
+            raise InvalidDataError(msg)
+        db_id = await self.mass.music.database.insert(
+            self.db_table,
+            {
+                "name": item.name,
+                "sort_name": item.sort_name,
+                "version": item.version,
+                "favorite": item.favorite,
+                "metadata": serialize_to_json(item.metadata),
+                "external_ids": serialize_to_json(item.external_ids),
+                "publisher": item.publisher,
+                "total_episodes": item.total_episodes,
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, item.provider_mappings)
+        self.logger.debug("added %s to database (id: %s)", item.name, db_id)
+        return db_id
+
+    async def _update_library_item(
+        self, item_id: str | int, update: Podcast, overwrite: bool = False
+    ) -> None:
+        """Update existing record in the database."""
+        db_id = int(item_id)  # ensure integer
+        cur_item = await self.get_library_item(db_id)
+        metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata)
+        cur_item.external_ids.update(update.external_ids)
+        provider_mappings = (
+            update.provider_mappings
+            if overwrite
+            else {*cur_item.provider_mappings, *update.provider_mappings}
+        )
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {
+                "name": update.name if overwrite else cur_item.name,
+                "sort_name": update.sort_name
+                if overwrite
+                else cur_item.sort_name or update.sort_name,
+                "version": update.version if overwrite else cur_item.version or update.version,
+                "metadata": serialize_to_json(metadata),
+                "external_ids": serialize_to_json(
+                    update.external_ids if overwrite else cur_item.external_ids
+                ),
+                "publisher": cur_item.publisher or update.publisher,
+                "total_episodes": cur_item.total_episodes or update.total_episodes,
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
+
+    async def _get_provider_podcast_episodes(
+        self, item_id: str, provider_instance_id_or_domain: str
+    ) -> list[Episode]:
+        """Return podcast episodes for the given provider podcast id."""
+        prov: MusicProvider = self.mass.get_provider(provider_instance_id_or_domain)
+        if prov is None:
+            return []
+        # prefer cache items (if any) - for streaming providers only
+        cache_base_key = prov.lookup_key
+        cache_key = f"podcast.{item_id}"
+        if (
+            prov.is_streaming_provider
+            and (cache := await self.mass.cache.get(cache_key, base_key=cache_base_key)) is not None
+        ):
+            return [Episode.from_dict(x) for x in cache]
+        # no items in cache - get listing from provider
+        items = await prov.get_podcast_episodes(item_id)
+        # store (serializable items) in cache
+        if prov.is_streaming_provider:
+            self.mass.create_task(
+                self.mass.cache.set(
+                    cache_key,
+                    [x.to_dict() for x in items],
+                    expiration=3600,
+                    base_key=cache_base_key,
+                ),
+            )
+
+        return items
+
+    async def _get_provider_dynamic_base_tracks(
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str,
+        limit: int = 25,
+    ) -> list[Track]:
+        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
+        msg = "Dynamic tracks not supported for Podcast MediaItem"
+        raise NotImplementedError(msg)
+
+    async def _get_dynamic_tracks(self, media_item: Podcast, limit: int = 25) -> list[Track]:
+        """Get dynamic list of tracks for given item, fallback/default implementation."""
+        msg = "Dynamic tracks not supported for Podcast MediaItem"
+        raise NotImplementedError(msg)
+
+    async def match_providers(self, db_podcast: Podcast) -> None:
+        """Try to find match on all (streaming) providers for the provided (database) podcast.
+
+        This is used to link objects of different providers/qualities together.
+        """
+        if db_podcast.provider != "library":
+            return  # Matching only supported for database items
+
+        async def find_prov_match(provider: MusicProvider):
+            self.logger.debug(
+                "Trying to match podcast %s on provider %s", db_podcast.name, provider.name
+            )
+            match_found = False
+            search_str = db_podcast.name
+            search_result = await self.search(search_str, provider.instance_id)
+            for search_result_item in search_result:
+                if not search_result_item.available:
+                    continue
+                if not compare_media_item(db_podcast, search_result_item):
+                    continue
+                # we must fetch the full podcast version, search results can be simplified objects
+                prov_podcast = await self.get_provider_item(
+                    search_result_item.item_id,
+                    search_result_item.provider,
+                    fallback=search_result_item,
+                )
+                if compare_podcast(db_podcast, prov_podcast):
+                    # 100% match, we update the db with the additional provider mapping(s)
+                    match_found = True
+                    for provider_mapping in search_result_item.provider_mappings:
+                        await self.add_provider_mapping(db_podcast.item_id, provider_mapping)
+                        db_podcast.provider_mappings.add(provider_mapping)
+            return match_found
+
+        # try to find match on all providers
+        cur_provider_domains = {x.provider_domain for x in db_podcast.provider_mappings}
+        for provider in self.mass.music.providers:
+            if provider.domain in cur_provider_domains:
+                continue
+            if ProviderFeature.SEARCH not in provider.supported_features:
+                continue
+            if not provider.library_supported(MediaType.PODCAST):
+                continue
+            if not provider.is_streaming_provider:
+                # matching on unique providers is pointless as they push (all) their content to MA
+                continue
+            if await find_prov_match(provider):
+                cur_provider_domains.add(provider.domain)
+            else:
+                self.logger.debug(
+                    "Could not find match for Podcast %s on provider %s",
+                    db_podcast.name,
+                    provider.name,
+                )
index b8424d9d8a2fd822b57fba84d49720573100c171..acb5a0f720124a0fa68102126962eebf0f118b9a 100644 (file)
@@ -41,9 +41,11 @@ from music_assistant.constants import (
     DB_TABLE_ALBUM_TRACKS,
     DB_TABLE_ALBUMS,
     DB_TABLE_ARTISTS,
+    DB_TABLE_AUDIOBOOKS,
     DB_TABLE_LOUDNESS_MEASUREMENTS,
     DB_TABLE_PLAYLISTS,
     DB_TABLE_PLAYLOG,
+    DB_TABLE_PODCASTS,
     DB_TABLE_PROVIDER_MAPPINGS,
     DB_TABLE_RADIOS,
     DB_TABLE_SETTINGS,
@@ -60,7 +62,9 @@ from music_assistant.models.core_controller import CoreController
 
 from .media.albums import AlbumsController
 from .media.artists import ArtistsController
+from .media.audiobooks import AudiobooksController
 from .media.playlists import PlaylistController
+from .media.podcasts import PodcastsController
 from .media.radio import RadioController
 from .media.tracks import TracksController
 
@@ -93,6 +97,8 @@ class MusicController(CoreController):
         self.tracks = TracksController(self.mass)
         self.radio = RadioController(self.mass)
         self.playlists = PlaylistController(self.mass)
+        self.audiobooks = AudiobooksController(self.mass)
+        self.podcasts = PodcastsController(self.mass)
         self.in_progress_syncs: list[SyncTask] = []
         self._sync_lock = asyncio.Lock()
         self.manifest.name = "Music controller"
@@ -247,6 +253,10 @@ class MusicController(CoreController):
                         return SearchResults(tracks=[item])
                     elif media_type == MediaType.PLAYLIST:
                         return SearchResults(playlists=[item])
+                    elif media_type == MediaType.AUDIOBOOK:
+                        return SearchResults(audiobooks=[item])
+                    elif media_type == MediaType.PODCAST:
+                        return SearchResults(podcasts=[item])
                     else:
                         return SearchResults()
 
@@ -297,6 +307,18 @@ class MusicController(CoreController):
                 for item in sublist
                 if item is not None
             ][:limit],
+            audiobooks=[
+                item
+                for sublist in zip_longest(*[x.audiobooks for x in results_per_provider])
+                for item in sublist
+                if item is not None
+            ][:limit],
+            podcasts=[
+                item
+                for sublist in zip_longest(*[x.podcasts for x in results_per_provider])
+                for item in sublist
+                if item is not None
+            ][:limit],
         )
 
     async def search_provider(
@@ -381,6 +403,10 @@ class MusicController(CoreController):
                     result.playlists = search_results
                 elif media_type == MediaType.RADIO:
                     result.radio = search_results
+                elif media_type == MediaType.AUDIOBOOK:
+                    result.audiobooks = search_results
+                elif media_type == MediaType.PODCAST:
+                    result.podcasts = search_results
         return result
 
     @api_command("music/browse")
@@ -638,6 +664,10 @@ class MusicController(CoreController):
                 result = searchresult.tracks
             elif media_item.media_type == MediaType.PLAYLIST:
                 result = searchresult.playlists
+            elif media_item.media_type == MediaType.AUDIOBOOK:
+                result = searchresult.audiobooks
+            elif media_item.media_type == MediaType.PODCAST:
+                result = searchresult.podcasts
             else:
                 result = searchresult.radio
             for item in result:
@@ -778,6 +808,8 @@ class MusicController(CoreController):
         | TracksController
         | RadioController
         | PlaylistController
+        | AudiobooksController
+        | PodcastsController
     ):
         """Return controller for MediaType."""
         if media_type == MediaType.ARTIST:
@@ -790,6 +822,10 @@ class MusicController(CoreController):
             return self.radio
         if media_type == MediaType.PLAYLIST:
             return self.playlists
+        if media_type == MediaType.AUDIOBOOK:
+            return self.audiobooks
+        if media_type == MediaType.PODCAST:
+            return self.podcasts
         return None
 
     def get_unique_providers(self) -> set[str]:
@@ -1071,6 +1107,8 @@ class MusicController(CoreController):
                 DB_TABLE_ARTISTS,
                 DB_TABLE_PLAYLISTS,
                 DB_TABLE_RADIOS,
+                DB_TABLE_AUDIOBOOKS,
+                DB_TABLE_PODCASTS,
                 DB_TABLE_ALBUM_TRACKS,
                 DB_TABLE_PLAYLOG,
                 DB_TABLE_PROVIDER_MAPPINGS,
@@ -1237,6 +1275,42 @@ class MusicController(CoreController):
             [timestamp_modified] INTEGER
             );"""
         )
+        await self.database.execute(
+            f"""
+            CREATE TABLE IF NOT EXISTS {DB_TABLE_AUDIOBOOKS}(
+            [item_id] INTEGER PRIMARY KEY AUTOINCREMENT,
+            [name] TEXT NOT NULL,
+            [sort_name] TEXT NOT NULL,
+            [favorite] BOOLEAN DEFAULT 0,
+            [publisher] TEXT NOT NULL,
+            [total_chapters] INTEGER NOT NULL,
+            [authors] json NOT NULL,
+            [narrators] json NOT NULL,
+            [metadata] json NOT NULL,
+            [external_ids] json NOT NULL,
+            [play_count] INTEGER DEFAULT 0,
+            [last_played] INTEGER DEFAULT 0,
+            [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
+            [timestamp_modified] INTEGER
+            );"""
+        )
+        await self.database.execute(
+            f"""
+            CREATE TABLE IF NOT EXISTS {DB_TABLE_PODCASTS}(
+            [item_id] INTEGER PRIMARY KEY AUTOINCREMENT,
+            [name] TEXT NOT NULL,
+            [sort_name] TEXT NOT NULL,
+            [favorite] BOOLEAN DEFAULT 0,
+            [publisher] TEXT NOT NULL,
+            [total_episodes] INTEGER NOT NULL,
+            [metadata] json NOT NULL,
+            [external_ids] json NOT NULL,
+            [play_count] INTEGER DEFAULT 0,
+            [last_played] INTEGER DEFAULT 0,
+            [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
+            [timestamp_modified] INTEGER
+            );"""
+        )
         await self.database.execute(
             f"""
             CREATE TABLE IF NOT EXISTS {DB_TABLE_ALBUM_TRACKS}(
@@ -1305,6 +1379,8 @@ class MusicController(CoreController):
             DB_TABLE_TRACKS,
             DB_TABLE_PLAYLISTS,
             DB_TABLE_RADIOS,
+            DB_TABLE_AUDIOBOOKS,
+            DB_TABLE_PODCASTS,
         ):
             # index on favorite column
             await self.database.execute(
@@ -1401,7 +1477,15 @@ class MusicController(CoreController):
     async def __create_database_triggers(self) -> None:
         """Create database triggers."""
         # triggers to auto update timestamps
-        for db_table in ("artists", "albums", "tracks", "playlists", "radios"):
+        for db_table in (
+            "artists",
+            "albums",
+            "tracks",
+            "playlists",
+            "radios",
+            "audiobooks",
+            "podcasts",
+        ):
             await self.database.execute(
                 f"""
                 CREATE TRIGGER IF NOT EXISTS update_{db_table}_timestamp
index 1d10fecb8485a326f3dfe22a1a63bb6e07695732..fc6267d9b312101444f3c80e65147a25b6e41593 100644 (file)
@@ -10,11 +10,13 @@ from music_assistant_models.enums import ExternalID, MediaType
 from music_assistant_models.media_items import (
     Album,
     Artist,
+    Audiobook,
     ItemMapping,
     MediaItem,
     MediaItemMetadata,
     MediaItemType,
     Playlist,
+    Podcast,
     Radio,
     Track,
 )
@@ -43,6 +45,13 @@ def compare_media_item(
         return compare_playlist(base_item, compare_item, strict)
     if base_item.media_type == MediaType.RADIO and compare_item.media_type == MediaType.RADIO:
         return compare_radio(base_item, compare_item, strict)
+    if (
+        base_item.media_type == MediaType.AUDIOBOOK
+        and compare_item.media_type == MediaType.AUDIOBOOK
+    ):
+        return compare_audiobook(base_item, compare_item, strict)
+    if base_item.media_type == MediaType.PODCAST and compare_item.media_type == MediaType.PODCAST:
+        return compare_podcast(base_item, compare_item, strict)
     return compare_item_mapping(base_item, compare_item, strict)
 
 
@@ -265,6 +274,97 @@ def compare_radio(
     return compare_strings(base_item.name, compare_item.name, strict=strict)
 
 
+def compare_audiobook(
+    base_item: Audiobook | ItemMapping | None,
+    compare_item: Audiobook | ItemMapping | None,
+    strict: bool = True,
+) -> bool | None:
+    """Compare two Audiobook items and return True if they match."""
+    if base_item is None or compare_item is None:
+        return False
+    # return early on exact item_id match
+    if compare_item_ids(base_item, compare_item):
+        return True
+
+    # return early on (un)matched external id
+    for ext_id in (
+        ExternalID.ASIN,
+        ExternalID.BARCODE,
+    ):
+        external_id_match = compare_external_ids(
+            base_item.external_ids, compare_item.external_ids, ext_id
+        )
+        if external_id_match is not None:
+            return external_id_match
+
+    # compare version
+    if not compare_version(base_item.version, compare_item.version):
+        return False
+    # compare name
+    if not compare_strings(base_item.name, compare_item.name, strict=True):
+        return False
+    if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)):
+        return True
+    # for strict matching we REQUIRE both items to be a real Audiobook object
+    assert isinstance(base_item, Audiobook)
+    assert isinstance(compare_item, Audiobook)
+    # compare publisher
+    if (
+        base_item.publisher
+        and compare_item.publisher
+        and not compare_strings(base_item.publisher, compare_item.publisher, strict=True)
+    ):
+        return False
+    # compare author(s)
+    for author in base_item.authors:
+        author_safe = create_safe_string(author)
+        if author_safe in [create_safe_string(x) for x in compare_item.authors]:
+            return True
+    return False
+
+
+def compare_podcast(
+    base_item: Podcast | ItemMapping | None,
+    compare_item: Podcast | ItemMapping | None,
+    strict: bool = True,
+) -> bool | None:
+    """Compare two Podcast items and return True if they match."""
+    if base_item is None or compare_item is None:
+        return False
+    # return early on exact item_id match
+    if compare_item_ids(base_item, compare_item):
+        return True
+
+    # return early on (un)matched external id
+    for ext_id in (
+        ExternalID.ASIN,
+        ExternalID.BARCODE,
+    ):
+        external_id_match = compare_external_ids(
+            base_item.external_ids, compare_item.external_ids, ext_id
+        )
+        if external_id_match is not None:
+            return external_id_match
+
+    # compare version
+    if not compare_version(base_item.version, compare_item.version):
+        return False
+    # compare name
+    if not compare_strings(base_item.name, compare_item.name, strict=True):
+        return False
+    if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)):
+        return True
+    # for strict matching we REQUIRE both items to be a real Podcast object
+    assert isinstance(base_item, Audiobook)
+    assert isinstance(compare_item, Audiobook)
+    # compare publisher
+    return not (
+        base_item.publisher
+        and compare_item.publisher
+        and not compare_strings(base_item.publisher, compare_item.publisher, strict=True)
+    )
+
+
 def compare_item_mapping(
     base_item: ItemMapping,
     compare_item: ItemMapping,
index 5fa57be51f50af66932cc5d8f591567109ff100e..fed41814b829b656b10c54615656f9fb55da0c91 100644 (file)
@@ -11,7 +11,10 @@ from music_assistant_models.errors import MediaNotFoundError, MusicAssistantErro
 from music_assistant_models.media_items import (
     Album,
     Artist,
+    Audiobook,
     BrowseFolder,
+    Chapter,
+    Episode,
     ItemMapping,
     MediaItemType,
     Playlist,
@@ -108,6 +111,18 @@ class MusicProvider(Provider):
             raise NotImplementedError
         yield  # type: ignore
 
+    async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]:
+        """Retrieve library/subscribed audiobooks from the provider."""
+        if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
+            raise NotImplementedError
+        yield  # type: ignore
+
+    async def get_library_podcasts(self) -> AsyncGenerator[Audiobook, None]:
+        """Retrieve library/subscribed podcasts from the provider."""
+        if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
+            raise NotImplementedError
+        yield  # type: ignore
+
     async def get_artist(self, prov_artist_id: str) -> Artist:
         """Get full artist details by id."""
         raise NotImplementedError
@@ -144,6 +159,26 @@ class MusicProvider(Provider):
         if ProviderFeature.LIBRARY_RADIOS in self.supported_features:
             raise NotImplementedError
 
+    async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:  # type: ignore[return]
+        """Get full audiobook details by id."""
+        if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
+            raise NotImplementedError
+
+    async def get_podcast(self, prov_podcast_id: str) -> Audiobook:  # type: ignore[return]
+        """Get full audiobook details by id."""
+        if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
+            raise NotImplementedError
+
+    async def get_chapter(self, prov_chapter_id: str) -> Chapter:  # type: ignore[return]
+        """Get (full) audiobook chapter details by id."""
+        if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
+            raise NotImplementedError
+
+    async def get_episode(self, prov_episode_id: str) -> Chapter:  # type: ignore[return]
+        """Get (full) podcast episode details by id."""
+        if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
+            raise NotImplementedError
+
     async def get_album_tracks(
         self,
         prov_album_id: str,  # type: ignore[return]
@@ -161,6 +196,22 @@ class MusicProvider(Provider):
         if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
             raise NotImplementedError
 
+    async def get_audiobook_chapters(
+        self,
+        prov_audiobook_id: str,
+    ) -> list[Chapter]:
+        """Get all Chapters for given audiobook id."""
+        if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
+            raise NotImplementedError
+
+    async def get_podcast_episodes(
+        self,
+        prov_podcast_id: str,
+    ) -> list[Episode]:
+        """Get all Episodes for given podcast id."""
+        if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
+            raise NotImplementedError
+
     async def library_add(self, item: MediaItemType) -> bool:
         """Add item to provider's library. Return true on success."""
         if (
@@ -188,6 +239,16 @@ class MusicProvider(Provider):
             and ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
         ):
             raise NotImplementedError
+        if (
+            item.media_type == MediaType.AUDIOBOOK
+            and ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            item.media_type == MediaType.PODCAST
+            and ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
         self.logger.info(
             "Provider %s does not support library edit, "
             "the action will only be performed in the local database.",
@@ -222,6 +283,16 @@ class MusicProvider(Provider):
             and ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
         ):
             raise NotImplementedError
+        if (
+            media_type == MediaType.AUDIOBOOK
+            and ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.PODCAST
+            and ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
         self.logger.info(
             "Provider %s does not support library edit, "
             "the action will only be performed in the local database.",
@@ -253,8 +324,10 @@ class MusicProvider(Provider):
         if ProviderFeature.SIMILAR_TRACKS in self.supported_features:
             raise NotImplementedError
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
-        """Get streamdetails for a track/radio."""
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
+        """Get streamdetails for a track/radio/chapter/episode."""
         raise NotImplementedError
 
     async def get_audio_stream(  # type: ignore[return]
@@ -291,9 +364,17 @@ class MusicProvider(Provider):
             return await self.get_playlist(prov_item_id)
         if media_type == MediaType.RADIO:
             return await self.get_radio(prov_item_id)
+        if media_type == MediaType.AUDIOBOOK:
+            return await self.get_audiobook(prov_item_id)
+        if media_type == MediaType.PODCAST:
+            return await self.get_podcast(prov_item_id)
+        if media_type == MediaType.CHAPTER:
+            return await self.get_chapter(prov_item_id)
+        if media_type == MediaType.EPISODE:
+            return await self.get_episode(prov_item_id)
         return await self.get_track(prov_item_id)
 
-    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]:
+    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]:  # noqa: PLR0915
         """Browse this provider's items.
 
         :param path: The path to browse, (e.g. provider_id://artists).
@@ -369,6 +450,32 @@ class MusicProvider(Provider):
             return await self.mass.music.playlists.library_items(
                 extra_query=query, extra_query_params=query_params
             )
+        if subpath == "audiobooks":
+            library_items = await self.mass.cache.get(
+                "audiobook",
+                default=[],
+                category=CacheCategory.LIBRARY_ITEMS,
+                base_key=self.instance_id,
+            )
+            library_items = cast(list[int], library_items)
+            query = "audiobooks.item_id in :ids"
+            query_params = {"ids": library_items}
+            return await self.mass.music.audiobooks.library_items(
+                extra_query=query, extra_query_params=query_params
+            )
+        if subpath == "podcasts":
+            library_items = await self.mass.cache.get(
+                "podcast",
+                default=[],
+                category=CacheCategory.LIBRARY_ITEMS,
+                base_key=self.instance_id,
+            )
+            library_items = cast(list[int], library_items)
+            query = "podcasts.item_id in :ids"
+            query_params = {"ids": library_items}
+            return await self.mass.music.podcasts.library_items(
+                extra_query=query, extra_query_params=query_params
+            )
         if subpath:
             # unknown path
             msg = "Invalid subpath"
@@ -426,6 +533,26 @@ class MusicProvider(Provider):
                     label="radios",
                 )
             )
+        if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
+            items.append(
+                BrowseFolder(
+                    item_id="audiobooks",
+                    provider=self.domain,
+                    path=path + "audiobooks",
+                    name="",
+                    label="audiobooks",
+                )
+            )
+        if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
+            items.append(
+                BrowseFolder(
+                    item_id="podcasts",
+                    provider=self.domain,
+                    path=path + "podcasts",
+                    name="",
+                    label="podcasts",
+                )
+            )
         return items
 
     async def recommendations(self) -> list[MediaItemType]:
@@ -535,6 +662,10 @@ class MusicProvider(Provider):
             return ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features
         if media_type == MediaType.RADIO:
             return ProviderFeature.LIBRARY_RADIOS in self.supported_features
+        if media_type == MediaType.AUDIOBOOK:
+            return ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features
+        if media_type == MediaType.PODCAST:
+            return ProviderFeature.LIBRARY_PODCASTS in self.supported_features
         return False
 
     def library_edit_supported(self, media_type: MediaType) -> bool:
@@ -549,6 +680,8 @@ class MusicProvider(Provider):
             return ProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
         if media_type == MediaType.RADIO:
             return ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
+        if media_type == MediaType.AUDIOBOOK:
+            return ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
         return False
 
     def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType, None]:
@@ -563,4 +696,8 @@ class MusicProvider(Provider):
             return self.get_library_playlists()
         if media_type == MediaType.RADIO:
             return self.get_library_radios()
+        if media_type == MediaType.AUDIOBOOK:
+            return self.get_library_audiobooks()
+        if media_type == MediaType.PODCAST:
+            return self.get_library_podcasts()
         raise NotImplementedError
index 89b787b3f9dd6a608b53458ce71939bf4c1a7ec6..77ac0f646c3c86cd046b0441ca893c2ccaf05ffc 100644 (file)
@@ -367,7 +367,9 @@ class MyDemoMusicprovider(MusicProvider):
         # Get a list of similar tracks based on the provided track.
         # This is only called if the provider supports the SIMILAR_TRACKS feature.
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Get streamdetails for a track/radio."""
         # Get stream details for a track or radio.
         # Implementing this method is MANDATORY to allow playback.
index 9474c50d7684be8836907262138c18914215b79b..1c1d5cbde85adb8bdbb38a2a1dbcbe2df2f6e264 100644 (file)
@@ -352,7 +352,9 @@ class AppleMusicProvider(MusicProvider):
                     found_tracks.append(self._parse_track(track))
         return found_tracks
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         stream_metadata = await self._fetch_song_stream_metadata(item_id)
         license_url = stream_metadata["hls-key-server-url"]
index 74d955e0ee0da17921fad8d22e9b45ec93d20518..ee1b57be55f0ce4684d1feadc6bce81adaea3c6b 100644 (file)
@@ -541,7 +541,9 @@ class BuiltinProvider(MusicProvider):
         )
         return media_info
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Get streamdetails for a track/radio."""
         media_info = await self._get_media_info(item_id)
         is_radio = media_info.get("icy-name") or not media_info.duration
index 3b6b89d5b5f88654b1439fe893bae5d38416201f..3404d61d5fd02390d4cdb3530385c768098942c7 100644 (file)
@@ -427,7 +427,9 @@ class DeezerProvider(MusicProvider):
         ]["data"][:limit]
         return [await self.get_track(track["SNG_ID"]) for track in tracks]
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         url_details, song_data = await self.gw_client.get_deezer_track_urls(item_id)
         url = url_details["sources"][0]["url"]
index dc530474c5dfb0275d41baaad56b2360d205dec1..8a332d771b14b70ff85b16ffe9c735d193e8caff 100644 (file)
@@ -632,7 +632,9 @@ class LocalFileSystemProvider(MusicProvider):
             await _file.write("#EXTM3U\n")
         return await self.get_playlist(filename)
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         library_item = await self.mass.music.tracks.get_library_item_by_prov_id(
             item_id, self.instance_id
index a76b235a13cd6888cad6d9bf42e3d6d40557425d..1317488f811ea0cbe587093e8aafc7ce3c87b0c6 100644 (file)
@@ -431,7 +431,9 @@ class JellyfinProvider(MusicProvider):
             for album in albums["Items"]
         ]
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         jellyfin_track = await self._client.get_track(item_id)
         mimetype = self._media_mime_type(jellyfin_track)
index 9aeae69cb2a8083a5c0a82a37903d4e2127fd73d..28c877aae39f707f8bab28bc4eba6fbc36372edb 100644 (file)
@@ -675,7 +675,9 @@ class OpenSonicProvider(MusicProvider):
             msg = f"Failed to remove songs from {prov_playlist_id}, check your permissions."
             raise ProviderPermissionDenied(msg) from ex
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Get the details needed to process a specified track."""
         try:
             sonic_song: SonicSong = await self._run_async(self._conn.getSong, item_id)
index 519a8feaeff32deba638592eec3d638a5ef39464..5d0472a39dada4c4aae9bc1f80306f1b890ca1df 100644 (file)
@@ -886,7 +886,9 @@ class PlexProvider(MusicProvider):
                 return albums
         return []
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Get streamdetails for a track."""
         plex_track = await self._get_data(item_id, PlexTrack)
         if not plex_track or not plex_track.media:
index 34402a8ffaeedf34bfe091046c5a11d6618c98dc..0d3fb281e5e9d77c0fa561feb104ceb680ef40fd 100644 (file)
@@ -401,7 +401,9 @@ class QobuzProvider(MusicProvider):
             playlist_track_ids=",".join(playlist_track_ids),
         )
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         streamdata = None
         for format_id in [27, 7, 6, 5]:
index b365bdb6ae91821ac03dc54bdd02fc041e4c2dd2..318c0aa24c2512ad095eb6daabdd3c077aca1b0c 100644 (file)
@@ -348,7 +348,9 @@ class RadioBrowserProvider(MusicProvider):
 
         return radio
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.RADIO
+    ) -> StreamDetails:
         """Get streamdetails for a radio station."""
         stream = await self.radios.station(uuid=item_id)
         if not stream:
index d90687301961f46d7c5f88e432b44d219195d196..f32b5fbda14935953a5b4e5e9170b0c234734bca 100644 (file)
@@ -211,7 +211,9 @@ class SiriusXMProvider(MusicProvider):
 
         return self._parse_radio(self._channels_by_id[prov_radio_id])
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.RADIO
+    ) -> StreamDetails:
         """Get streamdetails for a track/radio."""
         hls_path = f"http://{self._base_url}/{item_id}.m3u8"
 
index 82aaebf7baedca557f9c474e12407a1e96b810f8..f74e88e7249a5310cced2e0f2afd68d183173586 100644 (file)
@@ -308,7 +308,9 @@ class SoundcloudMusicProvider(MusicProvider):
 
         return tracks
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         url: str = await self._soundcloud.get_stream_url(track_id=item_id)
         return StreamDetails(
index a8df349834c8d8dbf1fa49c24aa6f7f414ab4366..a45adc905461033f1bbdce94038fef86e291488f 100644 (file)
@@ -536,7 +536,9 @@ class SpotifyProvider(MusicProvider):
         items = await self._get_data(endpoint, seed_tracks=prov_track_id, limit=limit)
         return [self._parse_track(item) for item in items["tracks"] if (item and item["id"])]
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         return StreamDetails(
             item_id=item_id,
index 40f5562f511fdb59a6800afb89fa80b78f88b226..6d978b2b2b37471c380f20425d7ffae821be255d 100644 (file)
@@ -150,7 +150,9 @@ class TestProvider(MusicProvider):
                     track_item_id = f"{artist_idx}_{album_idx}_{track_idx}"
                     yield await self.get_track(track_item_id)
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Get streamdetails for a track/radio."""
         return StreamDetails(
             provider=self.instance_id,
index 09711526573c428cefdaf2d2581da9484a507286..e1e9948bdd0baddb40d38a7e5dac9c26f6e0fc55 100644 (file)
@@ -564,7 +564,9 @@ class TidalProvider(MusicProvider):
         )
         return self._parse_playlist(playlist_obj)
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         tidal_session = await self._get_tidal_session()
         # make sure a valid track is requested.
index 8be3df297b5f4b308260eae04a254daa56846b20..2ba94ce59932dd53f8d3fff6e06d8c35451a7925 100644 (file)
@@ -238,7 +238,9 @@ class TuneInProvider(MusicProvider):
         await self.mass.cache.set(preset_id, result, base_key=cache_base_key)
         return result
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.RADIO
+    ) -> StreamDetails:
         """Get streamdetails for a radio station."""
         if item_id.startswith("http"):
             # custom url
index 5672e6ffd93eb98bffce4c2d885946afe753c5e6..c8835156ba875c54101d0c10743d1dba080d4062 100644 (file)
@@ -494,7 +494,9 @@ class YoutubeMusicProvider(MusicProvider):
             return tracks
         return []
 
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         stream_format = await self._get_stream_format(item_id=item_id)
         self.logger.debug("Found stream_format: %s for song %s", stream_format["format"], item_id)