ABS: Rewrite of provider, couple new features (#1948)
authorFabian Munkes <105975993+fmunkes@users.noreply.github.com>
Fri, 14 Feb 2025 00:04:25 +0000 (01:04 +0100)
committerGitHub <noreply@github.com>
Fri, 14 Feb 2025 00:04:25 +0000 (01:04 +0100)
music_assistant/providers/audiobookshelf/__init__.py
music_assistant/providers/audiobookshelf/abs_client.py [deleted file]
music_assistant/providers/audiobookshelf/abs_schema.py [deleted file]
music_assistant/providers/audiobookshelf/manifest.json
music_assistant/providers/audiobookshelf/parsers.py [new file with mode: 0644]
requirements_all.txt
tests/providers/jellyfin/__snapshots__/test_parsers.ambr
tests/providers/opensubsonic/__snapshots__/test_parsers.ambr

index 31cc4dc990ea993fbd9c663d993499580a4ca5aa..e41ac78215892da37eaf797119416f2556c57124 100644 (file)
@@ -1,52 +1,55 @@
-"""Audiobookshelf provider for Music Assistant.
-
-Audiobookshelf is abbreviated ABS here.
-"""
+"""Audiobookshelf (abs) provider for Music Assistant."""
 
 from __future__ import annotations
 
-import asyncio
 from collections.abc import AsyncGenerator, Sequence
+from dataclasses import dataclass, field
+from enum import StrEnum
 from typing import TYPE_CHECKING
 
+import aioaudiobookshelf as aioabs
+from aioaudiobookshelf.client.items import LibraryItemExpandedBook as AbsLibraryItemExpandedBook
+from aioaudiobookshelf.client.items import (
+    LibraryItemExpandedPodcast as AbsLibraryItemExpandedPodcast,
+)
+from aioaudiobookshelf.exceptions import LoginError as AbsLoginError
+from aioaudiobookshelf.schema.calls_authors import (
+    AuthorWithItemsAndSeries as AbsAuthorWithItemsAndSeries,
+)
+from aioaudiobookshelf.schema.calls_series import SeriesWithProgress as AbsSeriesWithProgress
+from aioaudiobookshelf.schema.library import (
+    LibraryItemExpanded,
+    LibraryItemExpandedBook,
+    LibraryItemExpandedPodcast,
+)
+from aioaudiobookshelf.schema.library import (
+    LibraryMediaType as AbsLibraryMediaType,
+)
+from mashumaro.mixins.dict import DataClassDictMixin
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
 from music_assistant_models.enums import (
     ConfigEntryType,
     ContentType,
-    ImageType,
     MediaType,
     ProviderFeature,
     StreamType,
 )
 from music_assistant_models.errors import LoginFailed, MediaNotFoundError
-from music_assistant_models.media_items import (
-    Audiobook,
-    AudioFormat,
-    BrowseFolder,
-    ItemMapping,
-    MediaItemChapter,
-    MediaItemImage,
-    MediaItemTypeOrItemMapping,
-    Podcast,
-    PodcastEpisode,
-    ProviderMapping,
-    UniqueList,
-)
+from music_assistant_models.media_items import AudioFormat, BrowseFolder, MediaItemTypeOrItemMapping
 from music_assistant_models.streamdetails import StreamDetails
 
+from music_assistant.helpers.ffmpeg import get_ffmpeg_stream
 from music_assistant.models.music_provider import MusicProvider
-from music_assistant.providers.audiobookshelf.abs_client import ABSClient, LibraryWithItemIDs
-from music_assistant.providers.audiobookshelf.abs_schema import (
-    ABSDeviceInfo,
-    ABSLibraryItemExpandedBook,
-    ABSLibraryItemExpandedPodcast,
-    ABSLibraryItemMinifiedBook,
-    ABSLibraryItemMinifiedPodcast,
-    ABSPlaybackSessionExpanded,
-    ABSPodcastEpisodeExpanded,
+from music_assistant.providers.audiobookshelf.parsers import (
+    parse_audiobook,
+    parse_podcast,
+    parse_podcast_episode,
 )
 
 if TYPE_CHECKING:
+    from aioaudiobookshelf.schema.events_socket import LibraryItemRemoved
+    from aioaudiobookshelf.schema.media_progress import MediaProgress
+    from music_assistant_models.media_items import Audiobook, Podcast, PodcastEpisode
     from music_assistant_models.provider import ProviderManifest
 
     from music_assistant.mass import MusicAssistant
@@ -59,6 +62,70 @@ CONF_VERIFY_SSL = "verify_ssl"
 # optionally hide podcasts with no episodes
 CONF_HIDE_EMPTY_PODCASTS = "hide_empty_podcasts"
 
+# We do _not_ store the full library, just the helper classes LibrariesHelper/ LibraryHelper,
+# see below, i.e. only uuids and the lib's name.
+# Caching these can be removed, but I'd then have to iterate the full item list
+# within the browse function if the user wishes to see all audiobooks/ podcasts
+# of a library.
+CACHE_CATEGORY_LIBRARIES = 0
+CACHE_KEY_LIBRARIES = "libraries"
+
+
+class AbsBrowsePaths(StrEnum):
+    """Path prefixes for browse view."""
+
+    LIBRARIES_BOOK = "lb"
+    LIBRARIES_PODCAST = "lp"
+    AUTHORS = "a"
+    NARRATORS = "n"
+    SERIES = "s"
+    COLLECTIONS = "c"
+    AUDIOBOOKS = "b"
+
+
+class AbsBrowseItemsBook(StrEnum):
+    """Folder names in browse view for books."""
+
+    AUTHORS = "Authors"
+    NARRATORS = "Narrators"
+    SERIES = "Series"
+    COLLECTIONS = "Collections"
+    AUDIOBOOKS = "Audiobooks"
+
+
+class AbsBrowseItemsPodcast(StrEnum):
+    """Folder names in browse view for podcasts."""
+
+    PODCASTS = "Podcasts"
+
+
+@dataclass(kw_only=True)
+class LibraryHelper(DataClassDictMixin):
+    """Lib name + media items' uuids."""
+
+    name: str
+    item_ids: set[str] = field(default_factory=set)
+
+
+@dataclass(kw_only=True)
+class LibrariesHelper(DataClassDictMixin):
+    """Helper class to store ABSLibrary name, id and the uuids of its media items.
+
+    Dictionary is lib_id:AbsLibraryWithItemIDs.
+    """
+
+    audiobooks: dict[str, LibraryHelper] = field(default_factory=dict)
+    podcasts: dict[str, LibraryHelper] = field(default_factory=dict)
+
+
+ABSBROWSEITEMSTOPATH: dict[str, str] = {
+    AbsBrowseItemsBook.AUTHORS: AbsBrowsePaths.AUTHORS,
+    AbsBrowseItemsBook.NARRATORS: AbsBrowsePaths.NARRATORS,
+    AbsBrowseItemsBook.SERIES: AbsBrowsePaths.SERIES,
+    AbsBrowseItemsBook.COLLECTIONS: AbsBrowsePaths.COLLECTIONS,
+    AbsBrowseItemsBook.AUDIOBOOKS: AbsBrowsePaths.AUDIOBOOKS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -138,32 +205,46 @@ class Audiobookshelf(MusicProvider):
 
     async def handle_async_init(self) -> None:
         """Pass config values to client and initialize."""
-        self._client = ABSClient()
         base_url = str(self.config.get_value(CONF_URL))
         username = str(self.config.get_value(CONF_USERNAME))
+        password = str(self.config.get_value(CONF_PASSWORD))
+        verify_ssl = bool(self.config.get_value(CONF_VERIFY_SSL))
+        session_config = aioabs.SessionConfiguration(
+            session=self.mass.http_session,
+            url=base_url,
+            verify_ssl=verify_ssl,
+            logger=self.logger,
+            pagination_items_per_page=30,  # audible provider goes with 50 for pagination
+        )
         try:
-            await self._client.init(
-                session=self.mass.http_session,
-                base_url=base_url,
-                username=username,
-                password=str(self.config.get_value(CONF_PASSWORD)),
-                logger=self.logger,
-                check_ssl=bool(self.config.get_value(CONF_VERIFY_SSL)),
+            self._client, self._client_socket = await aioabs.get_user_and_socket_client(
+                session_config=session_config, username=username, password=password
             )
-        except RuntimeError:
-            # login details were not correct
-            raise LoginFailed(f"Login to abs instance at {base_url} failed.")
-
-        # this will be provided when creating sessions or receive already opened sessions
-        self.device_info = ABSDeviceInfo(
-            device_id=self.instance_id,
-            client_name="Music Assistant",
-            client_version=self.mass.version,
-            manufacturer="",
-            model=self.mass.server_id,
+            await self._client_socket.init_client()
+        except AbsLoginError as exc:
+            raise LoginFailed(f"Login to abs instance at {base_url} failed.") from exc
+
+        self.cache_base_key = self.instance_id
+
+        cached_libraries = await self.mass.cache.get(
+            key=CACHE_KEY_LIBRARIES,
+            base_key=self.cache_base_key,
+            category=CACHE_CATEGORY_LIBRARIES,
+            default=None,
         )
+        if cached_libraries is None:
+            self.libraries = LibrariesHelper()
+        else:
+            self.libraries = LibrariesHelper.from_dict(cached_libraries)
 
-        self.logger.debug(f"Our playback session device_id is {self.instance_id}")
+        # set socket callbacks
+        self._client_socket.set_item_callbacks(
+            on_item_added=self._socket_abs_item_changed,
+            on_item_updated=self._socket_abs_item_changed,
+            on_item_removed=self._socket_abs_item_removed,
+            on_items_added=self._socket_abs_item_changed,
+            on_items_updated=self._socket_abs_item_changed,
+        )
 
     async def unload(self, is_removed: bool = False) -> None:
         """
@@ -172,8 +253,8 @@ class Audiobookshelf(MusicProvider):
         Called when provider is deregistered (e.g. MA exiting or config reloading).
         is_removed will be set to True when the provider is removed from the configuration.
         """
-        await self._client.close_all_playback_sessions()
         await self._client.logout()
+        await self._client_socket.logout()
 
     @property
     def is_streaming_provider(self) -> bool:
@@ -182,139 +263,112 @@ class Audiobookshelf(MusicProvider):
         return False
 
     async def sync_library(self, media_types: tuple[MediaType, ...]) -> None:
-        """Run library sync for this provider."""
-        await self._client.sync()
+        """Obtain audiobook library ids and podcast library ids."""
+        libraries = await self._client.get_all_libraries()
+        for library in libraries:
+            if (
+                library.media_type == AbsLibraryMediaType.BOOK
+                and MediaType.AUDIOBOOK in media_types
+            ):
+                self.libraries.audiobooks[library.id_] = LibraryHelper(name=library.name)
+            elif (
+                library.media_type == AbsLibraryMediaType.PODCAST
+                and MediaType.PODCAST in media_types
+            ):
+                self.libraries.podcasts[library.id_] = LibraryHelper(name=library.name)
         await super().sync_library(media_types=media_types)
+        await self._cache_set_helper_libraries()
 
-    def _parse_podcast(
-        self, abs_podcast: ABSLibraryItemExpandedPodcast | ABSLibraryItemMinifiedPodcast
-    ) -> Podcast:
-        """Translate ABSPodcast to MassPodcast."""
-        title = abs_podcast.media.metadata.title
-        # Per API doc title may be None.
-        if title is None:
-            title = "UNKNOWN"
-        mass_podcast = Podcast(
-            item_id=abs_podcast.id_,
-            name=title,
-            publisher=abs_podcast.media.metadata.author,
-            provider=self.lookup_key,
-            provider_mappings={
-                ProviderMapping(
-                    item_id=abs_podcast.id_,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                )
-            },
-        )
-        mass_podcast.metadata.description = abs_podcast.media.metadata.description
-        token = self._client.token
-        image_url = (
-            f"{self.config.get_value(CONF_URL)}/api/items/{abs_podcast.id_}/cover?token={token}"
-        )
-        mass_podcast.metadata.images = UniqueList(
-            [MediaItemImage(type=ImageType.THUMB, path=image_url, provider=self.lookup_key)]
-        )
-        mass_podcast.metadata.explicit = abs_podcast.media.metadata.explicit
-        if abs_podcast.media.metadata.language is not None:
-            mass_podcast.metadata.languages = UniqueList([abs_podcast.media.metadata.language])
-        if abs_podcast.media.metadata.genres is not None:
-            mass_podcast.metadata.genres = set(abs_podcast.media.metadata.genres)
-        mass_podcast.metadata.release_date = abs_podcast.media.metadata.release_date
-
-        if isinstance(abs_podcast, ABSLibraryItemExpandedPodcast):
-            mass_podcast.total_episodes = len(abs_podcast.media.episodes)
-        elif isinstance(abs_podcast, ABSLibraryItemMinifiedPodcast):
-            mass_podcast.total_episodes = abs_podcast.media.num_episodes
-
-        return mass_podcast
-
-    async def _parse_podcast_episode(
-        self,
-        episode: ABSPodcastEpisodeExpanded,
-        prov_podcast_id: str,
-        fallback_episode_cnt: int | None = None,
-    ) -> PodcastEpisode:
-        """Translate ABSPodcastEpisode to MassPodcastEpisode.
-
-        For an episode the id is set to f"{podcast_id} {episode_id}".
-        ABS ids have no spaces, so we can split at a space to retrieve both
-        in other functions.
-        """
-        url = f"{self.config.get_value(CONF_URL)}{episode.audio_track.content_url}"
-        episode_id = f"{prov_podcast_id} {episode.id_}"
+    async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
+        """Retrieve library/subscribed podcasts from the provider.
 
-        if episode.published_at is not None:
-            position = -episode.published_at
-        else:
-            position = 0
-            if fallback_episode_cnt is not None:
-                position = fallback_episode_cnt
-        mass_episode = PodcastEpisode(
-            item_id=episode_id,
-            provider=self.lookup_key,
-            name=episode.title,
-            duration=int(episode.duration),
-            position=position,
-            podcast=ItemMapping(
-                item_id=prov_podcast_id,
-                provider=self.lookup_key,
-                name=episode.title,
-                media_type=MediaType.PODCAST,
-            ),
-            provider_mappings={
-                ProviderMapping(
-                    item_id=episode_id,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                    audio_format=AudioFormat(
-                        content_type=ContentType.UNKNOWN,
-                    ),
-                    url=url,
+        Minified podcast information is enough, but we take the full information
+        and rely on cache afterwards.
+        """
+        for pod_lib_id in self.libraries.podcasts:
+            async for response in self._client.get_library_items(library_id=pod_lib_id):
+                if not response.results:
+                    break
+                podcast_ids = [x.id_ for x in response.results]
+                # store uuids
+                self.libraries.podcasts[pod_lib_id].item_ids.update(podcast_ids)
+                podcasts_expanded = await self._client.get_library_item_batch_podcast(
+                    item_ids=podcast_ids
                 )
-            },
-        )
-        progress, finished = await self._client.get_podcast_progress_ms(
-            prov_podcast_id, episode.id_
-        )
-        if progress is not None:
-            mass_episode.resume_position_ms = progress
-            mass_episode.fully_played = finished
-
-        # cover image
-        url_base = f"{self.config.get_value(CONF_URL)}"
-        url_api = f"/api/items/{prov_podcast_id}/cover?token={self._client.token}"
-        url_cover = f"{url_base}{url_api}"
-        mass_episode.metadata.images = UniqueList(
-            [MediaItemImage(type=ImageType.THUMB, path=url_cover, provider=self.lookup_key)]
-        )
+                for podcast_expanded in podcasts_expanded:
+                    mass_podcast = parse_podcast(
+                        abs_podcast=podcast_expanded,
+                        lookup_key=self.lookup_key,
+                        domain=self.domain,
+                        instance_id=self.instance_id,
+                        token=self._client.token,
+                        base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
+                    )
+                    if (
+                        bool(self.config.get_value(CONF_HIDE_EMPTY_PODCASTS))
+                        and mass_podcast.total_episodes == 0
+                    ):
+                        continue
+                    yield mass_podcast
 
-        return mass_episode
+    async def _get_abs_expanded_podcast(
+        self, prov_podcast_id: str
+    ) -> AbsLibraryItemExpandedPodcast:
+        abs_podcast = await self._client.get_library_item_podcast(
+            podcast_id=prov_podcast_id, expanded=True
+        )
+        assert isinstance(abs_podcast, AbsLibraryItemExpandedPodcast)
 
-    async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
-        """Retrieve library/subscribed podcasts from the provider."""
-        async for abs_podcast in self._client.get_all_podcasts_minified():
-            mass_podcast = self._parse_podcast(abs_podcast)
-            if (
-                bool(self.config.get_value(CONF_HIDE_EMPTY_PODCASTS))
-                and mass_podcast.total_episodes == 0
-            ):
-                continue
-            yield mass_podcast
+        return abs_podcast
 
     async def get_podcast(self, prov_podcast_id: str) -> Podcast:
-        """Get single podcast."""
-        abs_podcast = await self._client.get_podcast_expanded(prov_podcast_id)
-        return self._parse_podcast(abs_podcast)
+        """Get single podcast.
+
+        Basis information,
+        abs_podcast = await self._client.get_library_item_podcast(
+            podcast_id=prov_podcast_id, expanded=False
+        ),
+        would be sufficient, but we rely on cache.
+        """
+        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
+        return parse_podcast(
+            abs_podcast=abs_podcast,
+            lookup_key=self.lookup_key,
+            domain=self.domain,
+            instance_id=self.instance_id,
+            token=self._client.token,
+            base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
+        )
 
     async def get_podcast_episodes(self, prov_podcast_id: str) -> list[PodcastEpisode]:
-        """Get all podcast episodes of podcast."""
-        abs_podcast = await self._client.get_podcast_expanded(prov_podcast_id)
+        """Get all podcast episodes of podcast.
+
+        Adds progress information.
+        """
+        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
         episode_list = []
         episode_cnt = 1
+        # the user has the progress of all media items
+        # so we use a single api call here to obtain possibly many
+        # progresses for episodes
+        user = await self._client.get_my_user()
+        abs_progresses = {
+            x.episode_id: x
+            for x in user.media_progress
+            if x.episode_id is not None and x.library_item_id == prov_podcast_id
+        }
         for abs_episode in abs_podcast.media.episodes:
-            mass_episode = await self._parse_podcast_episode(
-                abs_episode, prov_podcast_id, episode_cnt
+            progress = abs_progresses.get(abs_episode.id_, None)
+            mass_episode = parse_podcast_episode(
+                episode=abs_episode,
+                prov_podcast_id=prov_podcast_id,
+                fallback_episode_cnt=episode_cnt,
+                lookup_key=self.lookup_key,
+                domain=self.domain,
+                instance_id=self.instance_id,
+                token=self._client.token,
+                base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
+                media_progress=progress,
             )
             episode_list.append(mass_episode)
             episode_cnt += 1
@@ -323,171 +377,144 @@ class Audiobookshelf(MusicProvider):
     async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
         """Get single podcast episode."""
         prov_podcast_id, e_id = prov_episode_id.split(" ")
-        abs_podcast = await self._client.get_podcast_expanded(prov_podcast_id)
+        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
         episode_cnt = 1
         for abs_episode in abs_podcast.media.episodes:
             if abs_episode.id_ == e_id:
-                return await self._parse_podcast_episode(abs_episode, prov_podcast_id, episode_cnt)
-
-            episode_cnt += 1
-        raise MediaNotFoundError("Episode not found")
-
-    async def _parse_audiobook(
-        self, abs_audiobook: ABSLibraryItemExpandedBook | ABSLibraryItemMinifiedBook
-    ) -> Audiobook:
-        mass_audiobook = Audiobook(
-            item_id=abs_audiobook.id_,
-            provider=self.lookup_key,
-            name=abs_audiobook.media.metadata.title,
-            duration=int(abs_audiobook.media.duration),
-            provider_mappings={
-                ProviderMapping(
-                    item_id=abs_audiobook.id_,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
+                progress = await self._client.get_my_media_progress(
+                    item_id=prov_podcast_id, episode_id=abs_episode.id_
                 )
-            },
-            publisher=abs_audiobook.media.metadata.publisher,
-        )
-        mass_audiobook.metadata.description = abs_audiobook.media.metadata.description
-        if abs_audiobook.media.metadata.language is not None:
-            mass_audiobook.metadata.languages = UniqueList([abs_audiobook.media.metadata.language])
-        mass_audiobook.metadata.release_date = abs_audiobook.media.metadata.published_date
-        if abs_audiobook.media.metadata.genres is not None:
-            mass_audiobook.metadata.genres = set(abs_audiobook.media.metadata.genres)
-
-        mass_audiobook.metadata.explicit = abs_audiobook.media.metadata.explicit
-
-        # cover
-        base_url = f"{self.config.get_value(CONF_URL)}"
-        api_url = f"/api/items/{abs_audiobook.id_}/cover?token={self._client.token}"
-        cover_url = f"{base_url}{api_url}"
-        mass_audiobook.metadata.images = UniqueList(
-            [MediaItemImage(type=ImageType.THUMB, path=cover_url, provider=self.lookup_key)]
-        )
-
-        # expanded version
-        if isinstance(abs_audiobook, ABSLibraryItemExpandedBook):
-            authors = UniqueList([x.name for x in abs_audiobook.media.metadata.authors])
-            narrators = UniqueList(abs_audiobook.media.metadata.narrators)
-            mass_audiobook.authors = authors
-            mass_audiobook.narrators = narrators
-            chapters = []
-            for idx, chapter in enumerate(abs_audiobook.media.chapters):
-                chapters.append(
-                    MediaItemChapter(
-                        position=idx + 1,  # chapter starting at 1
-                        name=chapter.title,
-                        start=chapter.start,
-                        end=chapter.end,
-                    )
+                return parse_podcast_episode(
+                    episode=abs_episode,
+                    prov_podcast_id=prov_podcast_id,
+                    fallback_episode_cnt=episode_cnt,
+                    lookup_key=self.lookup_key,
+                    domain=self.domain,
+                    instance_id=self.instance_id,
+                    token=self._client.token,
+                    base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
+                    media_progress=progress,
                 )
-            mass_audiobook.metadata.chapters = chapters
-
-            progress, finished = await self._client.get_audiobook_progress_ms(abs_audiobook.id_)
-            if progress is not None:
-                mass_audiobook.resume_position_ms = progress
-                mass_audiobook.fully_played = finished
-        elif isinstance(abs_audiobook, ABSLibraryItemMinifiedBook):
-            mass_audiobook.authors = UniqueList([abs_audiobook.media.metadata.author_name])
-            mass_audiobook.narrators = UniqueList([abs_audiobook.media.metadata.narrator_name])
 
-        return mass_audiobook
+            episode_cnt += 1
+        raise MediaNotFoundError("Episode not found")
 
     async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]:
         """Get Audiobook libraries.
 
-        We need the expanded version here to have chapters shown!
+        Need expanded version for chapters.
         """
-        async for abs_audiobook in self._client.get_all_audiobooks_minified():
-            abs_audiobook_expanded = await self._client.get_audiobook_expanded(abs_audiobook.id_)
-            mass_audiobook = await self._parse_audiobook(abs_audiobook_expanded)
-            yield mass_audiobook
+        for book_lib_id in self.libraries.audiobooks:
+            async for response in self._client.get_library_items(library_id=book_lib_id):
+                if not response.results:
+                    break
+                book_ids = [x.id_ for x in response.results]
+                # store uuids
+                self.libraries.audiobooks[book_lib_id].item_ids.update(book_ids)
+                # use expanded version for chapters/ caching.
+                books_expanded = await self._client.get_library_item_batch_book(item_ids=book_ids)
+                for book_expanded in books_expanded:
+                    mass_audiobook = parse_audiobook(
+                        abs_audiobook=book_expanded,
+                        lookup_key=self.lookup_key,
+                        domain=self.domain,
+                        instance_id=self.instance_id,
+                        token=self._client.token,
+                        base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
+                    )
+                    yield mass_audiobook
+
+    async def _get_abs_expanded_audiobook(
+        self, prov_audiobook_id: str
+    ) -> AbsLibraryItemExpandedBook:
+        abs_audiobook = await self._client.get_library_item_book(
+            book_id=prov_audiobook_id, expanded=True
+        )
+        assert isinstance(abs_audiobook, AbsLibraryItemExpandedBook)
+
+        return abs_audiobook
 
     async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
-        """Get a single audiobook."""
-        abs_audiobook = await self._client.get_audiobook_expanded(prov_audiobook_id)
-        return await self._parse_audiobook(abs_audiobook)
+        """Get a single audiobook.
 
-    async def get_streamdetails_from_playback_session(
-        self, session: ABSPlaybackSessionExpanded
-    ) -> StreamDetails:
-        """Give Streamdetails from given session."""
-        tracks = session.audio_tracks
-        if len(tracks) == 0:
-            raise RuntimeError("Playback session has no tracks to play")
-        track = tracks[0]
-        track_url = track.content_url
-        if track_url.split("/")[1] != "hls":
-            raise RuntimeError("Did expect HLS stream for session playback")
-        item_id = ""
-        if session.media_type == "podcast":
-            media_type = MediaType.PODCAST_EPISODE
-            podcast_id = session.library_item_id
-            session_id = session.id_
-            episode_id = session.episode_id
-            item_id = f"{podcast_id} {episode_id} {session_id}"
-        else:
-            media_type = MediaType.AUDIOBOOK
-            audiobook_id = session.library_item_id
-            session_id = session.id_
-            item_id = f"{audiobook_id} {session_id}"
-        token = self._client.token
-        base_url = str(self.config.get_value(CONF_URL))
-        media_url = track.content_url
-        stream_url = f"{base_url}{media_url}?token={token}"
-        return StreamDetails(
-            provider=self.instance_id,
-            item_id=item_id,
-            audio_format=AudioFormat(
-                content_type=ContentType.UNKNOWN,
-            ),
-            media_type=media_type,
-            stream_type=StreamType.HLS,
-            path=stream_url,
-            can_seek=True,
-            allow_seek=True,
+        Progress is added here.
+        """
+        progress = await self._client.get_my_media_progress(item_id=prov_audiobook_id)
+        abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=prov_audiobook_id)
+        return parse_audiobook(
+            abs_audiobook=abs_audiobook,
+            lookup_key=self.lookup_key,
+            domain=self.domain,
+            instance_id=self.instance_id,
+            token=self._client.token,
+            base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
+            media_progress=progress,
         )
 
     async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
         """Get stream of item."""
-        # self.logger.debug(f"Streamdetails: {item_id}")
         if media_type == MediaType.PODCAST_EPISODE:
-            return await self._get_stream_details_podcast_episode(item_id)
+            return await self._get_stream_details_episode(item_id)
         elif media_type == MediaType.AUDIOBOOK:
-            abs_audiobook = await self._client.get_audiobook_expanded(item_id)
-            tracks = abs_audiobook.media.tracks
-            if len(tracks) == 0:
-                raise MediaNotFoundError("Stream not found")
-            if len(tracks) > 1:
-                session = await self._client.get_playback_session_audiobook(
-                    device_info=self.device_info, audiobook_id=item_id
-                )
-                # small delay, allow abs to launch ffmpeg process
-                await asyncio.sleep(1)
-                return await self.get_streamdetails_from_playback_session(session)
+            abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=item_id)
             return await self._get_stream_details_audiobook(abs_audiobook)
         raise MediaNotFoundError("Stream unknown")
 
     async def _get_stream_details_audiobook(
-        self, abs_audiobook: ABSLibraryItemExpandedBook
+        self, abs_audiobook: AbsLibraryItemExpandedBook
     ) -> StreamDetails:
-        """Only single audio file in audiobook."""
-        self.logger.debug(
-            f"Using direct playback for audiobook {abs_audiobook.media.metadata.title}"
-        )
+        """Streamdetails audiobook."""
         tracks = abs_audiobook.media.tracks
         token = self._client.token
         base_url = str(self.config.get_value(CONF_URL))
-        media_url = tracks[0].content_url
+        if len(tracks) == 0:
+            raise MediaNotFoundError("Stream not found")
+        if len(tracks) > 1:
+            self.logger.debug("Using playback for multiple file audiobook.")
+            multiple_files = []
+            for track in tracks:
+                media_url = track.content_url
+                stream_url = f"{base_url}{media_url}?token={token}"
+                content_type = ContentType.UNKNOWN
+                if track.metadata is not None:
+                    content_type = ContentType.try_parse(track.metadata.ext)
+                multiple_files.append(
+                    (AudioFormat(content_type=content_type), stream_url, track.duration)
+                )
+
+            return StreamDetails(
+                provider=self.instance_id,
+                item_id=abs_audiobook.id_,
+                # for the concatanated stream, we need to use a pcm stream format
+                audio_format=AudioFormat(
+                    content_type=ContentType.PCM_S16LE,
+                    sample_rate=44100,
+                    bit_depth=16,
+                    channels=2,
+                ),
+                media_type=MediaType.AUDIOBOOK,
+                stream_type=StreamType.CUSTOM,
+                duration=int(abs_audiobook.media.duration),
+                data=multiple_files,
+                allow_seek=True,
+                can_seek=True,
+            )
+
+        self.logger.debug(
+            f'Using direct playback for audiobook "{abs_audiobook.media.metadata.title}".'
+        )
+
+        track = abs_audiobook.media.tracks[0]
+        media_url = track.content_url
         stream_url = f"{base_url}{media_url}?token={token}"
-        # audiobookshelf returns information of stream, so we should be able
-        # to lift unknown at some point.
+        content_type = ContentType.UNKNOWN
+        if track.metadata is not None:
+            content_type = ContentType.try_parse(track.metadata.ext)
         return StreamDetails(
             provider=self.lookup_key,
             item_id=abs_audiobook.id_,
             audio_format=AudioFormat(
-                content_type=ContentType.UNKNOWN,
+                content_type=content_type,
             ),
             media_type=MediaType.AUDIOBOOK,
             stream_type=StreamType.HTTP,
@@ -496,27 +523,30 @@ class Audiobookshelf(MusicProvider):
             allow_seek=True,
         )
 
-    async def _get_stream_details_podcast_episode(self, podcast_id: str) -> StreamDetails:
-        """Stream of a Podcast."""
+    async def _get_stream_details_episode(self, podcast_id: str) -> StreamDetails:
+        """Streamdetails of a podcast episode."""
         abs_podcast_id, abs_episode_id = podcast_id.split(" ")
         abs_episode = None
 
-        abs_podcast = await self._client.get_podcast_expanded(abs_podcast_id)
+        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=abs_podcast_id)
         for abs_episode in abs_podcast.media.episodes:
             if abs_episode.id_ == abs_episode_id:
                 break
         if abs_episode is None:
             raise MediaNotFoundError("Stream not found")
-        self.logger.debug(f"Using direct playback for podcast episode {abs_episode.title}")
+        self.logger.debug(f'Using direct playback for podcast episode "{abs_episode.title}".')
         token = self._client.token
         base_url = str(self.config.get_value(CONF_URL))
         media_url = abs_episode.audio_track.content_url
         full_url = f"{base_url}{media_url}?token={token}"
+        content_type = ContentType.UNKNOWN
+        if abs_episode.audio_track.metadata is not None:
+            content_type = ContentType.try_parse(abs_episode.audio_track.metadata.ext)
         return StreamDetails(
             provider=self.lookup_key,
             item_id=podcast_id,
             audio_format=AudioFormat(
-                content_type=ContentType.UNKNOWN,
+                content_type=content_type,
             ),
             media_type=MediaType.PODCAST_EPISODE,
             stream_type=StreamType.HTTP,
@@ -525,6 +555,52 @@ class Audiobookshelf(MusicProvider):
             allow_seek=True,
         )
 
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """
+        Return the (custom) audio stream for the provider item.
+
+        Only used for multi-file audiobooks.
+        """
+        stream_data: list[tuple[AudioFormat, str, float]] = streamdetails.data
+        total_duration = 0.0
+        for audio_format, chapter_file, chapter_duration in stream_data:
+            total_duration += chapter_duration
+            if total_duration < seek_position:
+                continue
+            seek_position_netto = round(
+                max(0, seek_position - (total_duration - chapter_duration)), 2
+            )
+            self.logger.debug(chapter_file)
+            async for chunk in get_ffmpeg_stream(
+                chapter_file,
+                input_format=audio_format,
+                # output format is always pcm because we are sending
+                # the result of multiple files as one big stream
+                output_format=streamdetails.audio_format,
+                extra_input_args=["-ss", str(seek_position_netto)] if seek_position_netto else [],
+            ):
+                yield chunk
+
+    async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:
+        """Return finished:bool, position_ms: int."""
+        progress: None | MediaProgress = None
+        if media_type == MediaType.PODCAST_EPISODE:
+            abs_podcast_id, abs_episode_id = item_id.split(" ")
+            progress = await self._client.get_my_media_progress(
+                item_id=abs_podcast_id, episode_id=abs_episode_id
+            )
+
+        if media_type == MediaType.AUDIOBOOK:
+            progress = await self._client.get_my_media_progress(item_id=item_id)
+
+        if progress is not None:
+            self.logger.debug("Resume position: obtained.")
+            return progress.is_finished, int(progress.current_time * 1000)
+
+        return False, 0
+
     async def on_played(
         self, media_type: MediaType, item_id: str, fully_played: bool, position: int
     ) -> None:
@@ -537,104 +613,413 @@ class Audiobookshelf(MusicProvider):
         We ignore PODCAST (function is called on adding a podcast with position=None)
 
         """
-        # self.logger.debug(f"on_played: {media_type=} {item_id=}, {fully_played=} {position=}")
         if media_type == MediaType.PODCAST_EPISODE:
             abs_podcast_id, abs_episode_id = item_id.split(" ")
             mass_podcast_episode = await self.get_podcast_episode(item_id)
             duration = mass_podcast_episode.duration
-            self.logger.debug(f"Updating of {media_type.value} named {mass_podcast_episode.name}")
-            await self._client.update_podcast_progress(
-                podcast_id=abs_podcast_id,
+            self.logger.debug(
+                f"Updating media progress of {media_type.value}, title {mass_podcast_episode.name}."
+            )
+            await self._client.update_my_media_progress(
+                item_id=abs_podcast_id,
                 episode_id=abs_episode_id,
-                progress_s=position,
-                duration_s=duration,
+                duration_seconds=duration,
+                progress_seconds=position,
                 is_finished=fully_played,
             )
         if media_type == MediaType.AUDIOBOOK:
             mass_audiobook = await self.get_audiobook(item_id)
             duration = mass_audiobook.duration
             self.logger.debug(f"Updating {media_type.value} named {mass_audiobook.name} progress")
-            await self._client.update_audiobook_progress(
-                audiobook_id=item_id,
-                progress_s=position,
-                duration_s=duration,
+            await self._client.update_my_media_progress(
+                item_id=item_id,
+                duration_seconds=duration,
+                progress_seconds=position,
                 is_finished=fully_played,
             )
 
-    async def _browse_root(
-        self, library_list: list[LibraryWithItemIDs], item_path: str
-    ) -> Sequence[MediaItemTypeOrItemMapping]:
-        """Browse root folder in browse view.
+    async def browse(self, path: str) -> Sequence[MediaItemTypeOrItemMapping]:
+        """Browse for audiobookshelf.
 
-        Helper functions. Shows the library name, ABS supports multiple libraries
-        of both podcasts and audiobooks.
+        Generates this view:
+        Library_Name_A (Audiobooks)
+            Audiobooks
+                Audiobook_1
+                Audiobook_2
+            Series
+                Series_1
+                    Audiobook_1
+                    Audiobook_2
+                Series_2
+                    Audiobook_3
+                    Audiobook_4
+            Collections
+                Collection_1
+                    Audiobook_1
+                    Audiobook_2
+                Collection_2
+                    Audiobook_3
+                    Audiobook_4
+            Authors
+                Author_1
+                    Series_1
+                    Audiobook_1
+                    Audiobook_2
+                Author_2
+                    Audiobook_3
+        Library_Name_B (Podcasts)
+            Podcast_1
+            Podcast_2
         """
-        items: list[MediaItemTypeOrItemMapping] = []
-        for library in library_list:
+        item_path = path.split("://", 1)[1]
+        if not item_path:
+            return self._browse_root()
+        sub_path = item_path.split("/")
+        lib_key, lib_id = sub_path[0].split(" ")
+        if len(sub_path) == 1:
+            if lib_key == AbsBrowsePaths.LIBRARIES_PODCAST:
+                return await self._browse_lib_podcasts(library_id=lib_id)
+            else:
+                return self._browse_lib_audiobooks(current_path=path)
+        elif len(sub_path) == 2:
+            item_key = sub_path[1]
+            match item_key:
+                case AbsBrowsePaths.AUTHORS:
+                    return await self._browse_authors(current_path=path, library_id=lib_id)
+                case AbsBrowsePaths.NARRATORS:
+                    return await self._browse_narrators(current_path=path, library_id=lib_id)
+                case AbsBrowsePaths.SERIES:
+                    return await self._browse_series(current_path=path, library_id=lib_id)
+                case AbsBrowsePaths.COLLECTIONS:
+                    return await self._browse_collections(current_path=path, library_id=lib_id)
+                case AbsBrowsePaths.AUDIOBOOKS:
+                    return await self._browse_books(library_id=lib_id)
+        elif len(sub_path) == 3:
+            item_key, item_id = sub_path[1:3]
+            match item_key:
+                case AbsBrowsePaths.AUTHORS:
+                    return await self._browse_author_books(current_path=path, author_id=item_id)
+                case AbsBrowsePaths.NARRATORS:
+                    return await self._browse_narrator_books(
+                        library_id=lib_id, narrator_filter_str=item_id
+                    )
+                case AbsBrowsePaths.SERIES:
+                    return await self._browse_series_books(series_id=item_id)
+                case AbsBrowsePaths.COLLECTIONS:
+                    return await self._browse_collection_books(collection_id=item_id)
+        elif len(sub_path) == 4:
+            # series within author
+            series_id = sub_path[3]
+            return await self._browse_series_books(series_id=series_id)
+        return []
+
+    def _browse_root(self) -> Sequence[MediaItemTypeOrItemMapping]:
+        items = []
+
+        def _get_folder(path: str, lib_id: str, lib_name: str) -> BrowseFolder:
+            return BrowseFolder(
+                item_id=lib_id,
+                name=lib_name,
+                provider=self.lookup_key,
+                path=f"{self.instance_id}://{path}",
+            )
+
+        for lib_id, lib in self.libraries.audiobooks.items():
+            path = f"{AbsBrowsePaths.LIBRARIES_BOOK} {lib_id}"
+            name = f"{lib.name} ({AbsBrowseItemsBook.AUDIOBOOKS})"
+            items.append(_get_folder(path, lib_id, name))
+        for lib_id, lib in self.libraries.podcasts.items():
+            path = f"{AbsBrowsePaths.LIBRARIES_PODCAST} {lib_id}"
+            name = f"{lib.name} ({AbsBrowseItemsPodcast.PODCASTS})"
+            items.append(_get_folder(path, lib_id, name))
+        return items
+
+    async def _browse_lib_podcasts(self, library_id: str) -> list[MediaItemTypeOrItemMapping]:
+        """No sub categories for podcasts."""
+        items = []
+        for podcast_id in self.libraries.podcasts[library_id].item_ids:
+            mass_item = await self.mass.music.get_library_item_by_prov_id(
+                media_type=MediaType.PODCAST,
+                item_id=podcast_id,
+                provider_instance_id_or_domain=self.instance_id,
+            )
+            if mass_item is not None:
+                items.append(mass_item)
+        return sorted(items, key=lambda x: x.name)
+
+    def _browse_lib_audiobooks(self, current_path: str) -> Sequence[MediaItemTypeOrItemMapping]:
+        items = []
+        for item_name in AbsBrowseItemsBook:
+            path = current_path + "/" + ABSBROWSEITEMSTOPATH[item_name]
             items.append(
                 BrowseFolder(
-                    item_id=library.id_,
-                    name=library.name,
+                    item_id=item_name.lower(),
+                    name=item_name,
                     provider=self.lookup_key,
-                    path=f"{self.instance_id}://{item_path}/{library.id_}",
+                    path=path,
                 )
             )
         return items
 
-    async def _browse_lib(
-        self,
-        library_id: str,
-        library_list: list[LibraryWithItemIDs],
-        media_type: MediaType,
+    async def _browse_authors(
+        self, current_path: str, library_id: str
     ) -> Sequence[MediaItemTypeOrItemMapping]:
-        """Browse lib folder in browse view.
+        abs_authors = await self._client.get_library_authors(library_id=library_id)
+        items = []
+        for author in abs_authors:
+            path = f"{current_path}/{author.id_}"
+            items.append(
+                BrowseFolder(
+                    item_id=author.id_,
+                    name=author.name,
+                    provider=self.lookup_key,
+                    path=path,
+                )
+            )
 
-        Helper functions. Shows the items which are part of an ABS library.
-        """
-        library = None
-        for library in library_list:
-            if library_id == library.id_:
+        return sorted(items, key=lambda x: x.name)
+
+    async def _browse_narrators(
+        self, current_path: str, library_id: str
+    ) -> Sequence[MediaItemTypeOrItemMapping]:
+        abs_narrators = await self._client.get_library_narrators(library_id=library_id)
+        items = []
+        for narrator in abs_narrators:
+            path = f"{current_path}/{narrator.id_}"
+            items.append(
+                BrowseFolder(
+                    item_id=narrator.id_,
+                    name=narrator.name,
+                    provider=self.lookup_key,
+                    path=path,
+                )
+            )
+
+        return sorted(items, key=lambda x: x.name)
+
+    async def _browse_series(
+        self, current_path: str, library_id: str
+    ) -> Sequence[MediaItemTypeOrItemMapping]:
+        items = []
+        async for response in self._client.get_library_series(library_id=library_id):
+            if not response.results:
                 break
-        if library is None:
-            raise MediaNotFoundError("Lib missing.")
+            for abs_series in response.results:
+                path = f"{current_path}/{abs_series.id_}"
+                items.append(
+                    BrowseFolder(
+                        item_id=abs_series.id_,
+                        name=abs_series.name,
+                        provider=self.lookup_key,
+                        path=path,
+                    )
+                )
+
+        return sorted(items, key=lambda x: x.name)
 
+    async def _browse_collections(
+        self, current_path: str, library_id: str
+    ) -> Sequence[MediaItemTypeOrItemMapping]:
+        items = []
+        async for response in self._client.get_library_collections(library_id=library_id):
+            if not response.results:
+                break
+            for abs_collection in response.results:
+                path = f"{current_path}/{abs_collection.id_}"
+                items.append(
+                    BrowseFolder(
+                        item_id=abs_collection.id_,
+                        name=abs_collection.name,
+                        provider=self.lookup_key,
+                        path=path,
+                    )
+                )
+        return sorted(items, key=lambda x: x.name)
+
+    async def _browse_books(self, library_id: str) -> Sequence[MediaItemTypeOrItemMapping]:
+        items = []
+        for book_id in self.libraries.audiobooks[library_id].item_ids:
+            mass_item = await self.mass.music.get_library_item_by_prov_id(
+                media_type=MediaType.AUDIOBOOK,
+                item_id=book_id,
+                provider_instance_id_or_domain=self.instance_id,
+            )
+            if mass_item is not None:
+                items.append(mass_item)
+        return sorted(items, key=lambda x: x.name)
+
+    async def _browse_author_books(
+        self, current_path: str, author_id: str
+    ) -> Sequence[MediaItemTypeOrItemMapping]:
         items: list[MediaItemTypeOrItemMapping] = []
-        if media_type in [MediaType.PODCAST, MediaType.AUDIOBOOK]:
-            for item_id in library.item_ids:
+
+        abs_author = await self._client.get_author(
+            author_id=author_id, include_items=True, include_series=True
+        )
+        if not isinstance(abs_author, AbsAuthorWithItemsAndSeries):
+            raise TypeError("Unexpected type of author.")
+
+        book_ids = {x.id_ for x in abs_author.library_items}
+        series_book_ids = set()
+
+        for series in abs_author.series:
+            series_book_ids.update([x.id_ for x in series.items])
+            path = f"{current_path}/{series.id_}"
+            items.append(
+                BrowseFolder(
+                    item_id=series.id_,
+                    name=f"{series.name} ({AbsBrowseItemsBook.SERIES})",
+                    provider=self.lookup_key,
+                    path=path,
+                )
+            )
+        book_ids = book_ids.difference(series_book_ids)
+        for book_id in book_ids:
+            mass_item = await self.mass.music.get_library_item_by_prov_id(
+                media_type=MediaType.AUDIOBOOK,
+                item_id=book_id,
+                provider_instance_id_or_domain=self.instance_id,
+            )
+            if mass_item is not None:
+                items.append(mass_item)
+
+        return items
+
+    async def _browse_narrator_books(
+        self, library_id: str, narrator_filter_str: str
+    ) -> Sequence[MediaItemTypeOrItemMapping]:
+        items: list[MediaItemTypeOrItemMapping] = []
+        async for response in self._client.get_library_items(
+            library_id=library_id, filter_str=f"narrators.{narrator_filter_str}"
+        ):
+            if not response.results:
+                break
+            for item in response.results:
                 mass_item = await self.mass.music.get_library_item_by_prov_id(
-                    media_type=media_type,
-                    item_id=item_id,
+                    media_type=MediaType.AUDIOBOOK,
+                    item_id=item.id_,
                     provider_instance_id_or_domain=self.instance_id,
                 )
                 if mass_item is not None:
                     items.append(mass_item)
-        else:
-            raise RuntimeError(f"Media type must not be {media_type}")
+
+        return sorted(items, key=lambda x: x.name)
+
+    async def _browse_series_books(self, series_id: str) -> Sequence[MediaItemTypeOrItemMapping]:
+        items = []
+
+        abs_series = await self._client.get_series(series_id=series_id, include_progress=True)
+        if not isinstance(abs_series, AbsSeriesWithProgress):
+            raise TypeError("Unexpected series type.")
+
+        for book_id in abs_series.progress.library_item_ids:
+            # these are sorted in abs by sequence
+            mass_item = await self.mass.music.get_library_item_by_prov_id(
+                media_type=MediaType.AUDIOBOOK,
+                item_id=book_id,
+                provider_instance_id_or_domain=self.instance_id,
+            )
+            if mass_item is not None:
+                items.append(mass_item)
+
         return items
 
-    async def browse(self, path: str) -> Sequence[MediaItemTypeOrItemMapping]:
-        """Browse features shows libraries names."""
-        item_path = path.split("://", 1)[1]
-        if not item_path:  # root
-            return await super().browse(path)
-
-        # HANDLE ROOT PATH
-        if item_path == "audiobooks":
-            library_list = self._client.audiobook_libraries
-            return await self._browse_root(library_list, item_path)
-        elif item_path == "podcasts":
-            library_list = self._client.podcast_libraries
-            return await self._browse_root(library_list, item_path)
-
-        # HANDLE WITHIN LIBRARY
-        library_type, library_id = item_path.split("/")
-        if library_type == "audiobooks":
-            library_list = self._client.audiobook_libraries
-            media_type = MediaType.AUDIOBOOK
-        elif library_type == "podcasts":
-            library_list = self._client.podcast_libraries
-            media_type = MediaType.PODCAST
-        else:
-            raise MediaNotFoundError("Specified Lib Type unknown")
+    async def _browse_collection_books(
+        self, collection_id: str
+    ) -> Sequence[MediaItemTypeOrItemMapping]:
+        items = []
+        abs_collection = await self._client.get_collection(collection_id=collection_id)
+        for book in abs_collection.books:
+            mass_item = await self.mass.music.get_library_item_by_prov_id(
+                media_type=MediaType.AUDIOBOOK,
+                item_id=book.id_,
+                provider_instance_id_or_domain=self.instance_id,
+            )
+            if mass_item is not None:
+                items.append(mass_item)
+        return items
+
+    async def _socket_abs_item_changed(
+        self, items: LibraryItemExpanded | list[LibraryItemExpanded]
+    ) -> None:
+        """For added and updated."""
+        abs_items = [items] if isinstance(items, LibraryItemExpanded) else items
+        for abs_item in abs_items:
+            if isinstance(abs_item, LibraryItemExpandedBook):
+                self.logger.debug(
+                    'Updated book "%s" via socket.', abs_item.media.metadata.title or ""
+                )
+                await self.mass.music.audiobooks.add_item_to_library(
+                    parse_audiobook(
+                        abs_audiobook=abs_item,
+                        lookup_key=self.lookup_key,
+                        domain=self.domain,
+                        instance_id=self.instance_id,
+                        token=self._client.token,
+                        base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
+                    ),
+                    overwrite_existing=True,
+                )
+                lib = self.libraries.audiobooks.get(abs_item.library_id, None)
+                if lib is not None:
+                    lib.item_ids.add(abs_item.id_)
+            elif isinstance(abs_item, LibraryItemExpandedPodcast):
+                self.logger.debug(
+                    'Updated podcast "%s" via socket.', abs_item.media.metadata.title or ""
+                )
+                mass_podcast = parse_podcast(
+                    abs_podcast=abs_item,
+                    lookup_key=self.lookup_key,
+                    domain=self.domain,
+                    instance_id=self.instance_id,
+                    token=self._client.token,
+                    base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
+                )
+                if not (
+                    bool(self.config.get_value(CONF_HIDE_EMPTY_PODCASTS))
+                    and mass_podcast.total_episodes == 0
+                ):
+                    await self.mass.music.podcasts.add_item_to_library(
+                        mass_podcast,
+                        overwrite_existing=True,
+                    )
+                    lib = self.libraries.podcasts.get(abs_item.library_id, None)
+                    if lib is not None:
+                        lib.item_ids.add(abs_item.id_)
+        await self._cache_set_helper_libraries()
 
-        return await self._browse_lib(library_id, library_list, media_type)
+    async def _socket_abs_item_removed(self, item: LibraryItemRemoved) -> None:
+        """Item removed."""
+        media_type: MediaType | None = None
+        for lib in self.libraries.audiobooks.values():
+            if item.id_ in lib.item_ids:
+                media_type = MediaType.AUDIOBOOK
+                lib.item_ids.remove(item.id_)
+                break
+        for lib in self.libraries.podcasts.values():
+            if item.id_ in lib.item_ids:
+                media_type = MediaType.PODCAST
+                lib.item_ids.remove(item.id_)
+                break
+
+        if media_type is not None:
+            mass_item = await self.mass.music.get_library_item_by_prov_id(
+                media_type=media_type,
+                item_id=item.id_,
+                provider_instance_id_or_domain=self.instance_id,
+            )
+            if mass_item is not None:
+                await self.mass.music.remove_item_from_library(
+                    media_type=media_type, library_item_id=mass_item.item_id
+                )
+                self.logger.debug('Removed %s "%s" via socket.', media_type.value, mass_item.name)
+
+        await self._cache_set_helper_libraries()
+
+    async def _cache_set_helper_libraries(self) -> None:
+        await self.mass.cache.set(
+            key=CACHE_KEY_LIBRARIES,
+            base_key=self.cache_base_key,
+            category=CACHE_CATEGORY_LIBRARIES,
+            data=self.libraries.to_dict(),
+        )
diff --git a/music_assistant/providers/audiobookshelf/abs_client.py b/music_assistant/providers/audiobookshelf/abs_client.py
deleted file mode 100644 (file)
index 2a5826f..0000000
+++ /dev/null
@@ -1,461 +0,0 @@
-"""Simple Client for Audiobookshelf.
-
-We only implement the functions necessary for mass.
-"""
-
-import logging
-from collections.abc import AsyncGenerator
-from dataclasses import dataclass, field
-from enum import Enum
-from typing import Any
-
-from aiohttp import ClientSession
-from mashumaro.exceptions import InvalidFieldValue, MissingField
-from music_assistant_models.media_items import UniqueList
-
-from music_assistant.providers.audiobookshelf.abs_schema import (
-    ABSDeviceInfo,
-    ABSLibrariesItemsMinifiedBookResponse,
-    ABSLibrariesItemsMinifiedPodcastResponse,
-    ABSLibrariesResponse,
-    ABSLibraryItemExpandedBook,
-    ABSLibraryItemExpandedPodcast,
-    ABSLibraryItemMinifiedBook,
-    ABSLibraryItemMinifiedPodcast,
-    ABSLoginResponse,
-    ABSMediaProgress,
-    ABSPlaybackSessionExpanded,
-    ABSPlayRequest,
-    ABSSessionUpdate,
-    ABSUser,
-)
-
-# use page calls in case of large libraries
-LIMIT_ITEMS_PER_PAGE = 10
-
-
-@dataclass
-class LibraryWithItemIDs:
-    """Helper class to store ABSLibrary, and the ids of the items associated."""
-
-    id_: str
-    name: str = ""
-    item_ids: UniqueList[str] = field(default_factory=UniqueList[str])
-
-
-class ABSStatus(Enum):
-    """ABS Status Enum."""
-
-    STATUS_OK = 200
-    STATUS_NOT_FOUND = 404
-
-
-class ABSClient:
-    """Simple Audiobookshelf client.
-
-    Only implements methods needed for Music Assistant.
-    """
-
-    def __init__(self) -> None:
-        """Client authorization."""
-        self.podcast_libraries: list[LibraryWithItemIDs] = []
-        self.audiobook_libraries: list[LibraryWithItemIDs] = []
-        self.user: ABSUser
-        self.check_ssl: bool
-        # I would like to receive opened sessions via the API, however, it appears
-        # that this only possible for closed sessions. That's probably because
-        # abs expects only a single session per device
-        self.open_playback_session_ids: UniqueList[str] = UniqueList([])
-
-    async def init(
-        self,
-        session: ClientSession,
-        base_url: str,
-        username: str,
-        password: str,
-        logger: logging.Logger | None = None,
-        check_ssl: bool = True,
-    ) -> None:
-        """Initialize."""
-        self.session = session
-        self.base_url = base_url
-        self.check_ssl = check_ssl
-
-        if logger is None:
-            self.logger = logging.getLogger(name="ABSClient")
-            self.logger.setLevel(logging.DEBUG)
-        else:
-            self.logger = logger
-
-        self.session_headers = {}
-        self.user = await self.login(username=username, password=password)
-        self.token: str = self.user.token
-        self.session_headers = {"Authorization": f"Bearer {self.token}"}
-
-    async def _post(
-        self,
-        endpoint: str,
-        data: dict[str, Any] | None = None,
-        add_api_endpoint: bool = True,
-    ) -> bytes:
-        """POST request to abs api.
-
-        login and logout endpoint do not have "api" in url
-        """
-        _endpoint = (
-            f"{self.base_url}/api/{endpoint}" if add_api_endpoint else f"{self.base_url}/{endpoint}"
-        )
-        response = await self.session.post(
-            _endpoint, json=data, ssl=self.check_ssl, headers=self.session_headers
-        )
-        status = response.status
-        if status != ABSStatus.STATUS_OK.value:
-            raise RuntimeError(f"API post call to {endpoint=} failed with {status=}.")
-        return await response.read()
-
-    async def _get(self, endpoint: str, params: dict[str, str | int] | None = None) -> bytes:
-        """GET request to abs api."""
-        _endpoint = f"{self.base_url}/api/{endpoint}"
-        response = await self.session.get(
-            _endpoint, params=params, ssl=self.check_ssl, headers=self.session_headers
-        )
-        status = response.status
-        if status not in [ABSStatus.STATUS_OK.value, ABSStatus.STATUS_NOT_FOUND.value]:
-            raise RuntimeError(f"API get call to {endpoint=} failed.")
-        if response.content_type == "application/json":
-            return await response.read()
-        elif status == ABSStatus.STATUS_NOT_FOUND.value:
-            return b""
-        else:
-            raise RuntimeError("Response must be json.")
-
-    async def _patch(self, endpoint: str, data: dict[str, Any] | None = None) -> None:
-        """PATCH request to abs api."""
-        _endpoint = f"{self.base_url}/api/{endpoint}"
-        response = await self.session.patch(
-            _endpoint, json=data, ssl=self.check_ssl, headers=self.session_headers
-        )
-        status = response.status
-        if status != ABSStatus.STATUS_OK.value:
-            raise RuntimeError(f"API patch call to {endpoint=} failed.")
-
-    async def login(self, username: str, password: str) -> ABSUser:
-        """Obtain user holding token from ABS with username/ password authentication."""
-        data = await self._post(
-            "login",
-            add_api_endpoint=False,
-            data={"username": username, "password": password},
-        )
-
-        return ABSLoginResponse.from_json(data).user
-
-    async def logout(self) -> None:
-        """Logout from ABS."""
-        await self._post("logout", add_api_endpoint=False)
-
-    async def get_authenticated_user(self) -> ABSUser:
-        """Get an ABS user."""
-        data = await self._get("me")
-        return ABSUser.from_json(data)
-
-    async def sync(self) -> None:
-        """Update available book and podcast libraries."""
-        data = await self._get("libraries")
-        try:
-            libraries = ABSLibrariesResponse.from_json(data)
-        except (MissingField, InvalidFieldValue) as exc:
-            self.logger.error(exc)
-            return
-        ids = [x.id_ for x in self.audiobook_libraries]
-        ids.extend([x.id_ for x in self.podcast_libraries])
-        for library in libraries.libraries:
-            media_type = library.media_type
-            if library.id_ not in ids:
-                _library = LibraryWithItemIDs(library.id_, library.name)
-                if media_type == "book":
-                    self.audiobook_libraries.append(_library)
-                elif media_type == "podcast":
-                    self.podcast_libraries.append(_library)
-        self.user = await self.get_authenticated_user()
-
-    async def get_all_podcasts_minified(self) -> AsyncGenerator[ABSLibraryItemMinifiedPodcast]:
-        """Get all available podcasts."""
-        for library in self.podcast_libraries:
-            async for podcast in self.get_all_podcasts_by_library_minified(library):
-                yield podcast
-
-    async def _get_lib_items(self, lib: LibraryWithItemIDs) -> AsyncGenerator[bytes]:
-        """Get library items with pagination.
-
-        Note:
-           - minified=1 -> minified items. However, there appears to be
-             a bug in abs, so we always get minified items. Still there for
-             consistency
-           - collapseseries=0 -> even if books are part of a series, they will be single items
-        """
-        page_cnt = 0
-        while True:
-            data = await self._get(
-                f"/libraries/{lib.id_}/items?minified=1&collapseseries=0",
-                params={"limit": LIMIT_ITEMS_PER_PAGE, "page": page_cnt},
-            )
-            page_cnt += 1
-            yield data
-
-    async def get_all_podcasts_by_library_minified(
-        self, lib: LibraryWithItemIDs
-    ) -> AsyncGenerator[ABSLibraryItemMinifiedPodcast]:
-        """Get all podcasts in a library."""
-        async for podcast_data in self._get_lib_items(lib):
-            try:
-                podcast_list = ABSLibrariesItemsMinifiedPodcastResponse.from_json(
-                    podcast_data
-                ).results
-            except (MissingField, InvalidFieldValue) as exc:
-                self.logger.error(exc)
-                return
-            if not podcast_list:  # [] if page exceeds
-                return
-
-            for podcast in podcast_list:
-                # store ids of library items for later use
-                lib.item_ids.append(podcast.id_)
-                yield podcast
-
-    async def get_podcast_expanded(self, id_: str) -> ABSLibraryItemExpandedPodcast:
-        """Get a single Podcast by ID."""
-        # this endpoint gives more podcast extra data
-        data = await self._get(f"items/{id_}?expanded=1")
-        try:
-            abs_podcast = ABSLibraryItemExpandedPodcast.from_json(data)
-        except (MissingField, InvalidFieldValue) as exc:
-            self.logger.error(exc)
-            raise RuntimeError from exc
-        return abs_podcast
-
-    async def _get_progress_ms(
-        self,
-        endpoint: str,
-    ) -> tuple[int | None, bool]:
-        data = await self._get(endpoint=endpoint)
-        if not data:
-            # entry doesn't exist, so it wasn't played yet
-            return 0, False
-        try:
-            abs_media_progress = ABSMediaProgress.from_json(data)
-        except (MissingField, InvalidFieldValue) as exc:
-            self.logger.error(exc)
-            return None, False
-
-        return (
-            int(abs_media_progress.current_time * 1000),
-            abs_media_progress.is_finished,
-        )
-
-    async def get_podcast_progress_ms(
-        self, podcast_id: str, episode_id: str
-    ) -> tuple[int | None, bool]:
-        """Get podcast progress."""
-        endpoint = f"me/progress/{podcast_id}/{episode_id}"
-        return await self._get_progress_ms(endpoint)
-
-    async def get_audiobook_progress_ms(self, audiobook_id: str) -> tuple[int | None, bool]:
-        """Get audiobook progress."""
-        endpoint = f"me/progress/{audiobook_id}"
-        return await self._get_progress_ms(endpoint)
-
-    async def _update_progress(
-        self,
-        endpoint: str,
-        progress_seconds: int,
-        duration_seconds: int,
-        is_finished: bool,
-    ) -> None:
-        """Update progress of media item.
-
-        0 <= progress_percent <= 1
-
-        Notes:
-            - progress in abs is percentage
-            - multiple parameters in one call don't work in all combinations
-            - currentTime is current position in s
-            - currentTime works only if duration is sent as well, but then don't
-              send progress at the same time.
-        """
-        await self._patch(
-            endpoint,
-            data={"isFinished": is_finished},
-        )
-        if is_finished:
-            self.logger.debug(f"Marked played {endpoint}")
-            return
-        percentage = progress_seconds / duration_seconds
-        await self._patch(
-            endpoint,
-            data={"progress": percentage},
-        )
-        await self._patch(
-            endpoint,
-            data={"duration": duration_seconds, "currentTime": progress_seconds},
-        )
-        self.logger.debug(f"Updated to {percentage * 100:.0f}%")
-
-    async def update_podcast_progress(
-        self,
-        podcast_id: str,
-        episode_id: str,
-        progress_s: int,
-        duration_s: int,
-        is_finished: bool = False,
-    ) -> None:
-        """Update podcast episode progress."""
-        endpoint = f"me/progress/{podcast_id}/{episode_id}"
-
-        await self._update_progress(endpoint, progress_s, duration_s, is_finished)
-
-    async def update_audiobook_progress(
-        self,
-        audiobook_id: str,
-        progress_s: int,
-        duration_s: int,
-        is_finished: bool = False,
-    ) -> None:
-        """Update audiobook progress."""
-        endpoint = f"me/progress/{audiobook_id}"
-        await self._update_progress(endpoint, progress_s, duration_s, is_finished)
-
-    async def get_all_audiobooks_minified(self) -> AsyncGenerator[ABSLibraryItemMinifiedBook]:
-        """Get all audiobooks."""
-        for library in self.audiobook_libraries:
-            async for book in self.get_all_audiobooks_by_library_minified(library):
-                yield book
-
-    async def get_all_audiobooks_by_library_minified(
-        self, lib: LibraryWithItemIDs
-    ) -> AsyncGenerator[ABSLibraryItemMinifiedBook]:
-        """Get all Audiobooks in a library."""
-        async for audiobook_data in self._get_lib_items(lib):
-            try:
-                audiobook_list = ABSLibrariesItemsMinifiedBookResponse.from_json(
-                    audiobook_data
-                ).results
-            except (MissingField, InvalidFieldValue) as exc:
-                self.logger.error(exc)
-                return
-            if not audiobook_list:  # [] if page exceeds
-                return
-
-            for audiobook in audiobook_list:
-                # store ids of library items for later use
-                lib.item_ids.append(audiobook.id_)
-                yield audiobook
-
-    async def get_audiobook_expanded(self, id_: str) -> ABSLibraryItemExpandedBook:
-        """Get a single Audiobook by ID."""
-        # this endpoint gives more audiobook extra data
-        audiobook = await self._get(f"items/{id_}?expanded=1")
-        try:
-            abs_book = ABSLibraryItemExpandedBook.from_json(audiobook)
-        except (MissingField, InvalidFieldValue) as exc:
-            self.logger.error(exc)
-            raise RuntimeError from exc
-        return abs_book
-
-    async def get_playback_session_podcast(
-        self, device_info: ABSDeviceInfo, podcast_id: str, episode_id: str
-    ) -> ABSPlaybackSessionExpanded:
-        """Get Podcast playback session.
-
-        Returns an open session if it is already available.
-        """
-        endpoint = f"items/{podcast_id}/play/{episode_id}"
-        # by adding in the media item id, we can have several
-        # open sessions (i.e. we are able to stream more than a single
-        # audiobook/ podcast from abs at the same time)
-        # also fixes preload in playlist
-        device_info.device_id += f"/{podcast_id}/{episode_id}"
-        return await self._get_playback_session(endpoint, device_info=device_info)
-
-    async def get_playback_session_audiobook(
-        self, device_info: ABSDeviceInfo, audiobook_id: str
-    ) -> ABSPlaybackSessionExpanded:
-        """Get Audiobook playback session.
-
-        Returns an open session if it is already available.
-        """
-        endpoint = f"items/{audiobook_id}/play"
-        # see podcast comment above
-        device_info.device_id += f"/{audiobook_id}"
-        return await self._get_playback_session(endpoint, device_info=device_info)
-
-    async def _get_playback_session(
-        self, endpoint: str, device_info: ABSDeviceInfo
-    ) -> ABSPlaybackSessionExpanded:
-        """Get an ABS Playback Session.
-
-        You can only have a single session per device.
-        """
-        play_request = ABSPlayRequest(
-            device_info=device_info,
-            force_direct_play=False,
-            force_transcode=False,
-            # specifying no supported mime types makes abs send the file
-            # via hls but without transcoding to another format
-            supported_mime_types=[],
-        )
-        data = await self._post(endpoint, data=play_request.to_dict())
-        try:
-            session = ABSPlaybackSessionExpanded.from_json(data)
-        except (MissingField, InvalidFieldValue) as exc:
-            self.logger.error(exc)
-            raise RuntimeError from exc
-
-        self.logger.debug(
-            f"Got playback session {session.id_} "
-            f"for {session.media_type} named {session.display_title}"
-        )
-        self.open_playback_session_ids.append(session.id_)
-        return session
-
-    async def close_playback_session(self, playback_session_id: str) -> None:
-        """Close an open playback session."""
-        # optional data would be ABSSessionUpdate
-        self.logger.debug(f"Closing playback session {playback_session_id=}")
-        await self._post(f"session/{playback_session_id}/close")
-
-    async def sync_playback_session(
-        self, playback_session_id: str, update: ABSSessionUpdate
-    ) -> None:
-        """Sync an open playback session."""
-        await self._post(f"session/{playback_session_id}/sync", data=update.to_dict())
-
-    # async def get_all_closed_playback_sessions(self) -> AsyncGenerator[ABSPlaybackSession]:
-    #     """Get library items with pagination.
-    #
-    #     This returns only sessions, which are already closed.
-    #     """
-    #     page_cnt = 0
-    #     while True:
-    #         data = await self._get(
-    #             "me/listening-sessions",
-    #             params={"itemsPerPage": LIMIT_ITEMS_PER_PAGE, "page": page_cnt},
-    #         )
-    #         page_cnt += 1
-    #
-    #         sessions = ABSSessionsResponse.from_json(data).sessions
-    #         # self.logger.debug([session.device_info for session in sessions])
-    #         if sessions:
-    #             for session in sessions:
-    #                 yield session
-    #         else:
-    #             return
-
-    async def close_all_playback_sessions(self) -> None:
-        """Cleanup all playback sessions opened by us."""
-        if self.open_playback_session_ids:
-            self.logger.debug("Closing our playback sessions.")
-        for session_id in self.open_playback_session_ids:
-            try:
-                await self.close_playback_session(session_id)
-            except RuntimeError:
-                self.logger.debug(f"Was unable to close session {session_id}")
diff --git a/music_assistant/providers/audiobookshelf/abs_schema.py b/music_assistant/providers/audiobookshelf/abs_schema.py
deleted file mode 100644 (file)
index 246ac43..0000000
+++ /dev/null
@@ -1,749 +0,0 @@
-"""Schema definition of Audiobookshelf (ABS).
-
-https://api.audiobookshelf.org/
-
-Some schema definitions have variants. Take book as example:
-https://api.audiobookshelf.org/#book
-Naming Scheme in this file:
-    - the standard definition has nothing added
-    - minified/ expanded: here, 2 additional variants
-
-Sometimes these variants remove or change attributes in such a way, that
-it makes sense to define a base class for inheritance.
-"""
-
-from dataclasses import dataclass, field
-from enum import Enum
-from typing import Annotated
-
-from mashumaro.config import BaseConfig
-from mashumaro.mixins.json import DataClassJSONMixin
-from mashumaro.types import Alias
-
-
-class BaseModel(DataClassJSONMixin):
-    """BaseModel for Schema.
-
-    forbid_extra_keys: response of API may have more keys than used by us
-    serialize_by_alias: when using to_json(), we get the Alias keys
-    """
-
-    class Config(BaseConfig):
-        """Config."""
-
-        forbid_extra_keys = False
-        serialize_by_alias = True
-
-
-@dataclass
-class ABSAudioTrack(BaseModel):
-    """ABS audioTrack. No variants.
-
-    https://api.audiobookshelf.org/#audio-track
-    """
-
-    # index: int | None
-    # start_offset: Annotated[float, Alias("startOffset")] = 0.0
-    # duration: float = 0.0
-    # title: str = ""
-    content_url: Annotated[str, Alias("contentUrl")] = ""
-    mime_type: str = ""
-    # metadata: # not needed for mass application
-
-
-@dataclass
-class ABSBookChapter(BaseModel):
-    """
-    ABSBookChapter. No variants.
-
-    https://api.audiobookshelf.org/#book-chapter
-    """
-
-    id_: Annotated[int, Alias("id")]
-    start: float
-    end: float
-    title: str
-
-
-@dataclass
-class ABSAudioBookmark(BaseModel):
-    """ABSAudioBookmark. No variants.
-
-    https://api.audiobookshelf.org/#audio-bookmark
-    """
-
-    library_item_id: Annotated[str, Alias("libraryItemId")]
-    title: str
-    time: float  # seconds
-    created_at: Annotated[int, Alias("createdAt")]  # unix epoch ms
-
-
-@dataclass
-class ABSUserPermissions(BaseModel):
-    """ABSUserPermissions. No variants.
-
-    https://api.audiobookshelf.org/#user-permissions
-    """
-
-    # download: bool
-    # update: bool
-    # delete: bool
-    # upload: bool
-    # access_all_libraries: Annotated[bool, Alias("accessAllLibraries")]
-    # access_all_tags: Annotated[bool, Alias("accessAllTags")]
-    # access_explicit_content: Annotated[bool, Alias("accessExplicitContent")]
-
-
-@dataclass
-class ABSLibrary(BaseModel):
-    """ABSLibrary. No variants.
-
-    https://api.audiobookshelf.org/#library
-    Only attributes we need
-    """
-
-    id_: Annotated[str, Alias("id")]
-    name: str
-    # folders
-    # displayOrder: Integer
-    # icon: String
-    media_type: Annotated[str, Alias("mediaType")]
-    # provider: str
-    # settings
-    # created_at: Annotated[int, Alias("createdAt")]
-    # last_update: Annotated[int, Alias("lastUpdate")]
-
-
-@dataclass
-class ABSDeviceInfo(BaseModel):
-    """ABSDeviceInfo. No variants.
-
-    https://api.audiobookshelf.org/#device-info-parameters
-    https://api.audiobookshelf.org/#device-info
-    https://github.com/advplyr/audiobookshelf/blob/master/server/objects/DeviceInfo.js#L3
-    """
-
-    device_id: Annotated[str, Alias("deviceId")] = ""
-    client_name: Annotated[str, Alias("clientName")] = ""
-    client_version: Annotated[str, Alias("clientVersion")] = ""
-    manufacturer: str = ""
-    model: str = ""
-    # sdkVersion # meant for an Android client
-
-
-### Author: https://api.audiobookshelf.org/#author
-
-
-@dataclass
-class ABSAuthorMinified(BaseModel):
-    """ABSAuthorMinified.
-
-    https://api.audiobookshelf.org/#author
-    """
-
-    id_: Annotated[str, Alias("id")]
-    name: str
-
-
-@dataclass
-class ABSAuthor(ABSAuthorMinified):
-    """ABSAuthor."""
-
-    # asin: str | None
-    description: str | None
-    # image_path: Annotated[str | None, Alias("imagePath")]
-    # added_at: Annotated[int, Alias("addedAt")]  # ms epoch
-    # updated_at: Annotated[int, Alias("updatedAt")]  # ms epoch
-
-
-@dataclass
-class ABSAuthorExpanded(ABSAuthor):
-    """ABSAuthorExpanded."""
-
-    num_books: Annotated[int, Alias("numBooks")]
-
-
-### Series: https://api.audiobookshelf.org/#series
-
-
-@dataclass
-class _ABSSeriesBase(BaseModel):
-    """_ABSSeriesBase."""
-
-    id_: Annotated[str, Alias("id")]
-    name: str
-
-
-@dataclass
-class ABSSeries(_ABSSeriesBase):
-    """ABSSeries."""
-
-    description: str | None
-    # added_at: Annotated[int, Alias("addedAt")]  # ms epoch
-    # updated_at: Annotated[int, Alias("updatedAt")]  # ms epoch
-
-
-@dataclass
-class ABSSeriesNumBooks(_ABSSeriesBase):
-    """ABSSeriesNumBooks."""
-
-    name_ignore_prefix: Annotated[str, Alias("nameIgnorePrefix")]
-    library_item_ids: Annotated[list[str], Alias("libraryItemIds")]
-    num_books: Annotated[int, Alias("numBooks")]
-
-
-@dataclass
-class ABSSeriesSequence(BaseModel):
-    """Series Sequence.
-
-    https://api.audiobookshelf.org/#series
-    """
-
-    id_: Annotated[str, Alias("id")]
-    name: str
-    sequence: str | None
-
-
-# another variant, ABSSeriesBooks is further down
-
-
-###  https://api.audiobookshelf.org/#media-progress
-
-
-@dataclass
-class ABSMediaProgress(BaseModel):
-    """ABSMediaProgress."""
-
-    id_: Annotated[str, Alias("id")]
-    library_item_id: Annotated[str, Alias("libraryItemId")]
-    episode_id: Annotated[str, Alias("episodeId")]
-    duration: float  # seconds
-    progress: float  # percent 0->1
-    current_time: Annotated[float, Alias("currentTime")]  # seconds
-    is_finished: Annotated[bool, Alias("isFinished")]
-    # hide_from_continue_listening: Annotated[bool, Alias("hideFromContinueListening")]
-    # last_update: Annotated[int, Alias("lastUpdate")]  # ms epoch
-    # started_at: Annotated[int, Alias("startedAt")]  # ms epoch
-    # finished_at: Annotated[int | None, Alias("finishedAt")]  # ms epoch
-
-
-# two additional progress variants, 'with media' book and podcast, further down.
-
-
-@dataclass
-class ABSUser(BaseModel):
-    """ABSUser.
-
-    only attributes we need for mass
-    https://api.audiobookshelf.org/#user
-    """
-
-    id_: Annotated[str, Alias("id")]
-    username: str
-    type_: Annotated[str, Alias("type")]
-    token: str
-    media_progress: Annotated[list[ABSMediaProgress], Alias("mediaProgress")]
-    # series_hide_from_continue_listening: Annotated[
-    #     list[str], Alias("seriesHideFromContinueListening")
-    # ]
-    # bookmarks: list[ABSAudioBookmark]
-    # is_active: Annotated[bool, Alias("isActive")]
-    # is_locked: Annotated[bool, Alias("isLocked")]
-    # last_seen: Annotated[int | None, Alias("lastSeen")]
-    # created_at: Annotated[int, Alias("createdAt")]
-    # permissions: ABSUserPermissions
-    libraries_accessible: Annotated[list[str], Alias("librariesAccessible")]
-
-    # this seems to be missing
-    # item_tags_accessible: Annotated[list[str], Alias("itemTagsAccessible")]
-
-
-# two additional user variants do exist
-
-
-class ABSPlayMethod(Enum):
-    """Playback method in playback session."""
-
-    DIRECT_PLAY = 0
-    DIRECT_STREAM = 1
-    TRANSCODE = 2
-    LOCAL = 3
-
-
-### https://api.audiobookshelf.org/#playback-session
-
-
-@dataclass
-class ABSPlaybackSession(BaseModel):
-    """ABSPlaybackSession."""
-
-    id_: Annotated[str, Alias("id")]
-    # user_id: Annotated[str, Alias("userId")]
-    # library_id: Annotated[str, Alias("libraryId")]
-    library_item_id: Annotated[str, Alias("libraryItemId")]
-    episode_id: Annotated[str | None, Alias("episodeId")]
-    media_type: Annotated[str, Alias("mediaType")]
-    # media_metadata: Annotated[ABSPodcastMetaData | ABSAudioBookMetaData, Alias("mediaMetadata")]
-    # chapters: list[ABSAudioBookChapter]
-    display_title: Annotated[str, Alias("displayTitle")]
-    # display_author: Annotated[str, Alias("displayAuthor")]
-    # cover_path: Annotated[str, Alias("coverPath")]
-    # duration: float
-    # 0: direct play, 1: direct stream, 2: transcode, 3: local
-    # play_method: Annotated[ABSPlayMethod, Alias("playMethod")]
-    # media_player: Annotated[str, Alias("mediaPlayer")]
-    # device_info: Annotated[ABSDeviceInfo, Alias("deviceInfo")]
-    # server_version: Annotated[str, Alias("serverVersion")]
-    # YYYY-MM-DD
-    # date: str
-    # day_of_week: Annotated[str, Alias("dayOfWeek")]
-    # time_listening: Annotated[float, Alias("timeListening")]  # s
-    # start_time: Annotated[float, Alias("startTime")]  # s
-    # current_time: Annotated[float, Alias("currentTime")]  # s
-    # started_at: Annotated[int, Alias("startedAt")]  # ms since Unix Epoch
-    # updated_at: Annotated[int, Alias("updatedAt")]  # ms since Unix Epoch
-
-
-@dataclass
-class ABSPlaybackSessionExpanded(ABSPlaybackSession):
-    """ABSPlaybackSessionExpanded."""
-
-    audio_tracks: Annotated[list[ABSAudioTrack], Alias("audioTracks")]
-
-    # videoTrack:
-    # libraryItem:
-
-
-### https://api.audiobookshelf.org/#podcast-metadata
-
-
-@dataclass
-class ABSPodcastMetadata(BaseModel):
-    """ABSPodcastMetadata."""
-
-    title: str | None
-    author: str | None
-    description: str | None
-    release_date: Annotated[str | None, Alias("releaseDate")]
-    genres: list[str] | None
-    # feed_url: Annotated[str | None, Alias("feedUrl")]
-    # image_url: Annotated[str | None, Alias("imageUrl")]
-    # itunes_page_url: Annotated[str | None, Alias("itunesPageUrl")]
-    # itunes_id: Annotated[int | None, Alias("itunesId")]
-    # itunes_artist_id: Annotated[int | None, Alias("itunesArtistId")]
-    explicit: bool
-    language: str | None
-    # type_: Annotated[str | None, Alias("type")]
-
-
-@dataclass
-class ABSPodcastMetadataMinified(ABSPodcastMetadata):
-    """ABSPodcastMetadataMinified."""
-
-    # title_ignore_prefix: Annotated[str, Alias("titleIgnorePrefix")]
-
-
-ABSPodcastMetaDataExpanded = ABSPodcastMetadataMinified
-
-### https://api.audiobookshelf.org/#podcast-episode
-
-
-@dataclass
-class ABSPodcastEpisode(BaseModel):
-    """ABSPodcastEpisode."""
-
-    library_item_id: Annotated[str, Alias("libraryItemId")]
-    id_: Annotated[str, Alias("id")]
-    index: int | None
-    # audio_file: # not needed for mass application
-    published_at: Annotated[int | None, Alias("publishedAt")]  # ms posix epoch
-    added_at: Annotated[int | None, Alias("addedAt")]  # ms posix epoch
-    updated_at: Annotated[int | None, Alias("updatedAt")]  # ms posix epoch
-    # season: str = ""
-    episode: str = ""
-    # episode_type: Annotated[str, Alias("episodeType")] = ""
-    title: str = ""
-    subtitle: str = ""
-    description: str = ""
-    # enclosure: str = ""
-    pub_date: Annotated[str, Alias("pubDate")] = ""
-    # guid: str = ""
-    # chapters
-
-
-@dataclass
-class ABSPodcastEpisodeExpanded(BaseModel):
-    """ABSPodcastEpisode.
-
-    https://api.audiobookshelf.org/#podcast-episode
-    """
-
-    library_item_id: Annotated[str, Alias("libraryItemId")]
-    id_: Annotated[str, Alias("id")]
-    index: int | None
-    # audio_file: # not needed for mass application
-    published_at: Annotated[int | None, Alias("publishedAt")]  # ms posix epoch
-    added_at: Annotated[int | None, Alias("addedAt")]  # ms posix epoch
-    # updated_at: Annotated[int | None, Alias("updatedAt")]  # ms posix epoch
-    audio_track: Annotated[ABSAudioTrack, Alias("audioTrack")]
-    # size: int  # in bytes
-    # season: str = ""
-    episode: str = ""
-    # episode_type: Annotated[str, Alias("episodeType")] = ""
-    title: str = ""
-    subtitle: str = ""
-    description: str = ""
-    # enclosure: str = ""
-    # pub_date: Annotated[str, Alias("pubDate")] = ""
-    # guid: str = ""
-    # chapters
-    duration: float = 0.0
-
-
-@dataclass
-class _ABSPodcastBase(BaseModel):
-    """_ABSPodcastBase."""
-
-    cover_path: Annotated[str, Alias("coverPath")]
-
-
-### https://api.audiobookshelf.org/#podcast
-
-
-@dataclass
-class ABSPodcast(_ABSPodcastBase):
-    """ABSPodcast."""
-
-    metadata: ABSPodcastMetadata
-    library_item_id: Annotated[str, Alias("libraryItemId")]
-    tags: list[str]
-    episodes: list[ABSPodcastEpisode]
-
-
-@dataclass
-class ABSPodcastMinified(_ABSPodcastBase):
-    """ABSPodcastMinified."""
-
-    metadata: ABSPodcastMetadataMinified
-    # size: int  # bytes
-    num_episodes: Annotated[int, Alias("numEpisodes")] = 0
-
-
-@dataclass
-class ABSPodcastExpanded(_ABSPodcastBase):
-    """ABSPodcastEpisodeExpanded."""
-
-    size: int  # bytes
-    metadata: ABSPodcastMetaDataExpanded
-    episodes: list[ABSPodcastEpisodeExpanded]
-
-
-### https://api.audiobookshelf.org/#book-metadata
-
-
-@dataclass
-class _ABSBookMetadataBase(BaseModel):
-    """_ABSBookMetadataBase."""
-
-    title: str
-    subtitle: str
-    genres: list[str] | None
-    published_year: Annotated[str | None, Alias("publishedYear")]
-    published_date: Annotated[str | None, Alias("publishedDate")]
-    publisher: str | None
-    description: str | None
-    # isbn: str | None
-    # asin: str | None
-    language: str | None
-    explicit: bool
-
-
-@dataclass
-class ABSBookMetadata(_ABSBookMetadataBase):
-    """ABSBookMetadata."""
-
-    authors: list[ABSAuthorMinified]
-    narrators: list[str]
-    series: list[ABSSeriesSequence]
-
-
-@dataclass
-class ABSBookMetadataMinified(_ABSBookMetadataBase):
-    """ABSBookMetadataMinified."""
-
-    # these are normally there
-    # title_ignore_prefix: Annotated[str, Alias("titleIgnorePrefix")]
-    author_name: Annotated[str, Alias("authorName")]
-    # author_name_lf: Annotated[str, Alias("authorNameLF")]
-    narrator_name: Annotated[str, Alias("narratorName")]
-    series_name: Annotated[str, Alias("seriesName")]
-
-
-@dataclass
-class ABSBookMetadataExpanded(ABSBookMetadata, ABSBookMetadataMinified):
-    """ABSAudioBookMetaDataExpanded."""
-
-
-### https://api.audiobookshelf.org/#book
-
-
-@dataclass
-class _ABSBookBase(BaseModel):
-    """_ABSBookBase."""
-
-    tags: list[str]
-    cover_path: Annotated[str | None, Alias("coverPath")]
-
-
-@dataclass
-class ABSBook(_ABSBookBase):
-    """ABSBook."""
-
-    library_item_id: Annotated[str, Alias("libraryItemId")]
-    metadata: ABSBookMetadata
-    # audioFiles
-    chapters: list[ABSBookChapter]
-    # ebookFile
-
-
-@dataclass
-class ABSBookMinified(_ABSBookBase):
-    """ABSBookBase."""
-
-    metadata: ABSBookMetadataMinified
-    # num_tracks: Annotated[int, Alias("numTracks")]
-    # num_audiofiles: Annotated[int, Alias("numAudioFiles")]
-    num_chapters: Annotated[int, Alias("numChapters")]
-    duration: float  # in s
-    # size: int  # in bytes
-    # ebookFormat
-
-
-@dataclass
-class ABSBookExpanded(_ABSBookBase):
-    """ABSBookExpanded."""
-
-    library_item_id: Annotated[str, Alias("libraryItemId")]
-    metadata: ABSBookMetadataExpanded
-    chapters: list[ABSBookChapter]
-    duration: float
-    size: int  # bytes
-    tracks: list[ABSAudioTrack]
-
-
-### https://api.audiobookshelf.org/#library-item
-
-
-@dataclass
-class _ABSLibraryItemBase(BaseModel):
-    """_ABSLibraryItemBase."""
-
-    id_: Annotated[str, Alias("id")]
-    # ino: str
-    # library_id: Annotated[str, Alias("libraryId")]
-    # folder_id: Annotated[str, Alias("folderId")]
-    # path: str
-    # relative_path: Annotated[str, Alias("relPath")]
-    # is_file: Annotated[bool, Alias("isFile")]
-    # last_modified_ms: Annotated[int, Alias("mtimeMs")]  # epoch
-    # last_changed_ms: Annotated[int, Alias("ctimeMs")]  # epoch
-    # birthtime_ms: Annotated[int, Alias("birthtimeMs")]  # epoch
-    # added_at: Annotated[int, Alias("addedAt")]  # ms epoch
-    # updated_at: Annotated[int, Alias("updatedAt")]  # ms epoch
-    # is_missing: Annotated[bool, Alias("isMissing")]
-    # is_invalid: Annotated[bool, Alias("isInvalid")]
-    media_type: Annotated[str, Alias("mediaType")]
-
-
-@dataclass
-class _ABSLibraryItem(_ABSLibraryItemBase):
-    """ABSLibraryItem."""
-
-    # last_scan: Annotated[int | None, Alias("lastScan")]  # ms epoch
-    # scan_version: Annotated[str | None, Alias("scanVersion")]
-    # libraryFiles
-
-
-@dataclass
-class ABSLibraryItemBook(_ABSLibraryItem):
-    """ABSLibraryItemBook."""
-
-    media: ABSBook
-
-
-@dataclass
-class ABSLibraryItemBookSeries(ABSLibraryItemBook):
-    """ABSLibraryItemNormalBookSeries.
-
-    Special class, when having the scheme of SeriesBooks, see
-    https://api.audiobookshelf.org/#series, it gets an extra
-    sequence key.
-    """
-
-    sequence: int
-
-
-@dataclass
-class ABSLibraryItemPodcast(_ABSLibraryItem):
-    """ABSLibraryItemPodcast."""
-
-    media: ABSPodcast
-
-
-@dataclass
-class _ABSLibraryItemMinified(_ABSLibraryItemBase):
-    """ABSLibraryItemMinified."""
-
-    num_files: Annotated[int, Alias("numFiles")]
-    size: int  # bytes
-
-
-@dataclass
-class ABSLibraryItemMinifiedBook(_ABSLibraryItemMinified):
-    """ABSLibraryItemMinifiedBook."""
-
-    media: ABSBookMinified
-
-
-@dataclass
-class ABSLibraryItemMinifiedPodcast(_ABSLibraryItemMinified):
-    """ABSLibraryItemMinifiedBook."""
-
-    media: ABSPodcastMinified
-
-
-@dataclass
-class _ABSLibraryItemExpanded(_ABSLibraryItemBase):
-    """ABSLibraryItemExpanded."""
-
-    size: int  # bytes
-
-
-@dataclass
-class ABSLibraryItemExpandedBook(_ABSLibraryItemExpanded):
-    """ABSLibraryItemExpanded."""
-
-    media: ABSBookExpanded
-
-
-@dataclass
-class ABSLibraryItemExpandedPodcast(_ABSLibraryItemExpanded):
-    """ABSLibraryItemExpanded."""
-
-    media: ABSPodcastExpanded
-
-
-# extra classes down here so they can make proper references
-
-
-@dataclass
-class ABSSeriesBooks(_ABSSeriesBase):
-    """ABSSeriesBooks."""
-
-    added_at: Annotated[int, Alias("addedAt")]  # ms epoch
-    # name_ignore_prefix: Annotated[str, Alias("nameIgnorePrefix")]
-    # name_ignore_prefix_sort: Annotated[str, Alias("nameIgnorePrefixSort")]
-    # type_: Annotated[str, Alias("type")]
-    books: list[ABSLibraryItemBookSeries]
-    total_duration: Annotated[float, Alias("totalDuration")]  # s
-
-
-@dataclass
-class ABSMediaProgressWithMediaBook(ABSMediaProgress):
-    """ABSMediaProgressWithMediaBook."""
-
-    media: ABSBookExpanded
-
-
-@dataclass
-class ABSMediaProgressWithMediaPodcast(ABSMediaProgress):
-    """ABSMediaProgressWithMediaBook."""
-
-    media: ABSPodcastExpanded
-    episode: ABSPodcastEpisode
-
-
-### Response to API Requests
-
-
-@dataclass
-class ABSLoginResponse(BaseModel):
-    """ABSLoginResponse."""
-
-    user: ABSUser
-
-    # this seems to be missing
-    # user_default_library_id: Annotated[str, Alias("defaultLibraryId")]
-
-
-@dataclass
-class ABSLibrariesResponse(BaseModel):
-    """ABSLibrariesResponse."""
-
-    libraries: list[ABSLibrary]
-
-
-@dataclass
-class ABSSessionsResponse(BaseModel):
-    """Response to GET http://abs.example.com/api/me/listening-sessions."""
-
-    total: int
-    num_pages: Annotated[int, Alias("numPages")]
-    items_per_page: Annotated[int, Alias("itemsPerPage")]
-    sessions: list[ABSPlaybackSession]
-
-
-@dataclass
-class ABSLibrariesItemsMinifiedBookResponse(BaseModel):
-    """ABSLibrariesItemsResponse.
-
-    https://api.audiobookshelf.org/#get-a-library-39-s-items
-    No matter what options I append to the request, I always end up with
-    minified items. Maybe a bug in abs. If that would be fixed, there is
-    potential for reduced in API calls.
-    """
-
-    results: list[ABSLibraryItemMinifiedBook]
-
-
-@dataclass
-class ABSLibrariesItemsMinifiedPodcastResponse(BaseModel):
-    """ABSLibrariesItemsResponse.
-
-    see above.
-    """
-
-    results: list[ABSLibraryItemMinifiedPodcast]
-
-
-### Requests to API we can make
-
-
-@dataclass
-class ABSPlayRequest(BaseModel):
-    """ABSPlayRequest.
-
-    https://api.audiobookshelf.org/#play-a-library-item-or-podcast-episode
-    """
-
-    device_info: Annotated[ABSDeviceInfo, Alias("deviceInfo")]
-    force_direct_play: Annotated[bool, Alias("forceDirectPlay")] = False
-    force_transcode: Annotated[bool, Alias("forceTranscode")] = False
-    supported_mime_types: Annotated[list[str], Alias("supportedMimeTypes")] = field(
-        default_factory=list
-    )
-    media_player: Annotated[str, Alias("mediaPlayer")] = "unknown"
-
-
-@dataclass
-class ABSSessionUpdate(BaseModel):
-    """
-    ABSSessionUpdate.
-
-    Can be used as optional data to sync or closing request.
-    unit is seconds
-    """
-
-    current_time: Annotated[float, Alias("currentTime")]
-    time_listened: Annotated[float, Alias("timeListened")]
-    duration: float
index 4762620728a0846ebe85e7c27665ae4bfe404d3a..4289f59bc11054f633dee5054d8c83ee2a3a772a 100644 (file)
@@ -6,6 +6,9 @@
   "codeowners": [
     "@fmunkes"
   ],
+  "requirements": [
+    "aioaudiobookshelf==0.1.0"
+  ],
   "documentation": "https://music-assistant.io/music-providers/audiobookshelf",
   "multi_instance": true
 }
diff --git a/music_assistant/providers/audiobookshelf/parsers.py b/music_assistant/providers/audiobookshelf/parsers.py
new file mode 100644 (file)
index 0000000..c028d51
--- /dev/null
@@ -0,0 +1,224 @@
+"""Parser for ABS -> MASS."""
+
+from aioaudiobookshelf.schema.library import (
+    LibraryItemExpandedBook as AbsLibraryItemExpandedBook,
+)
+from aioaudiobookshelf.schema.library import (
+    LibraryItemExpandedPodcast as AbsLibraryItemExpandedPodcast,
+)
+from aioaudiobookshelf.schema.library import (
+    LibraryItemMinifiedBook as AbsLibraryItemMinifiedBook,
+)
+from aioaudiobookshelf.schema.library import (
+    LibraryItemMinifiedPodcast as AbsLibraryItemMinifiedPodcast,
+)
+from aioaudiobookshelf.schema.library import (
+    LibraryItemPodcast as AbsLibraryItemPodcast,
+)
+from aioaudiobookshelf.schema.media_progress import MediaProgress as AbsMediaProgress
+from aioaudiobookshelf.schema.podcast import PodcastEpisodeExpanded as AbsPodcastEpisodeExpanded
+from music_assistant_models.enums import ContentType, ImageType, MediaType
+from music_assistant_models.media_items import Audiobook as MassAudiobook
+from music_assistant_models.media_items import (
+    AudioFormat,
+    ItemMapping,
+    MediaItemChapter,
+    MediaItemImage,
+    ProviderMapping,
+    UniqueList,
+)
+from music_assistant_models.media_items import Podcast as MassPodcast
+from music_assistant_models.media_items import PodcastEpisode as MassPodcastEpisode
+
+
+def parse_podcast(
+    *,
+    abs_podcast: AbsLibraryItemExpandedPodcast
+    | AbsLibraryItemMinifiedPodcast
+    | AbsLibraryItemPodcast,
+    lookup_key: str,
+    domain: str,
+    instance_id: str,
+    token: str | None,
+    base_url: str,
+) -> MassPodcast:
+    """Translate ABSPodcast to MassPodcast."""
+    title = abs_podcast.media.metadata.title
+    # Per API doc title may be None.
+    if title is None:
+        title = "UNKNOWN"
+    mass_podcast = MassPodcast(
+        item_id=abs_podcast.id_,
+        name=title,
+        publisher=abs_podcast.media.metadata.author,
+        provider=lookup_key,
+        provider_mappings={
+            ProviderMapping(
+                item_id=abs_podcast.id_,
+                provider_domain=domain,
+                provider_instance=instance_id,
+            )
+        },
+    )
+    mass_podcast.metadata.description = abs_podcast.media.metadata.description
+    if token is not None:
+        image_url = f"{base_url}/api/items/{abs_podcast.id_}/cover?token={token}"
+        mass_podcast.metadata.images = UniqueList(
+            [MediaItemImage(type=ImageType.THUMB, path=image_url, provider=lookup_key)]
+        )
+    mass_podcast.metadata.explicit = abs_podcast.media.metadata.explicit
+    if abs_podcast.media.metadata.language is not None:
+        mass_podcast.metadata.languages = UniqueList([abs_podcast.media.metadata.language])
+    if abs_podcast.media.metadata.genres is not None:
+        mass_podcast.metadata.genres = set(abs_podcast.media.metadata.genres)
+    mass_podcast.metadata.release_date = abs_podcast.media.metadata.release_date
+
+    if isinstance(abs_podcast, AbsLibraryItemExpandedPodcast):
+        mass_podcast.total_episodes = len(abs_podcast.media.episodes)
+    elif isinstance(abs_podcast, AbsLibraryItemMinifiedPodcast):
+        mass_podcast.total_episodes = abs_podcast.media.num_episodes
+
+    return mass_podcast
+
+
+def parse_podcast_episode(
+    *,
+    episode: AbsPodcastEpisodeExpanded,
+    prov_podcast_id: str,
+    fallback_episode_cnt: int | None = None,
+    lookup_key: str,
+    domain: str,
+    instance_id: str,
+    token: str | None,
+    base_url: str,
+    media_progress: AbsMediaProgress | None = None,
+) -> MassPodcastEpisode:
+    """Translate ABSPodcastEpisode to MassPodcastEpisode.
+
+    For an episode the id is set to f"{podcast_id} {episode_id}".
+    ABS ids have no spaces, so we can split at a space to retrieve both
+    in other functions.
+    """
+    url = f"{base_url}{episode.audio_track.content_url}"
+    episode_id = f"{prov_podcast_id} {episode.id_}"
+
+    if episode.published_at is not None:
+        position = -episode.published_at
+    else:
+        position = 0
+        if fallback_episode_cnt is not None:
+            position = fallback_episode_cnt
+    mass_episode = MassPodcastEpisode(
+        item_id=episode_id,
+        provider=lookup_key,
+        name=episode.title,
+        duration=int(episode.duration),
+        position=position,
+        podcast=ItemMapping(
+            item_id=prov_podcast_id,
+            provider=lookup_key,
+            name=episode.title,
+            media_type=MediaType.PODCAST,
+        ),
+        provider_mappings={
+            ProviderMapping(
+                item_id=episode_id,
+                provider_domain=domain,
+                provider_instance=instance_id,
+                audio_format=AudioFormat(
+                    content_type=ContentType.UNKNOWN,
+                ),
+                url=url,
+            )
+        },
+    )
+
+    # cover image
+    if token is not None:
+        url_api = f"/api/items/{prov_podcast_id}/cover?token={token}"
+        url_cover = f"{base_url}{url_api}"
+        mass_episode.metadata.images = UniqueList(
+            [MediaItemImage(type=ImageType.THUMB, path=url_cover, provider=lookup_key)]
+        )
+
+    if media_progress is not None:
+        mass_episode.resume_position_ms = int(media_progress.current_time * 1000)
+        mass_episode.fully_played = media_progress.is_finished
+
+    return mass_episode
+
+
+def parse_audiobook(
+    *,
+    abs_audiobook: AbsLibraryItemExpandedBook | AbsLibraryItemMinifiedBook,
+    lookup_key: str,
+    domain: str,
+    instance_id: str,
+    token: str | None,
+    base_url: str,
+    media_progress: AbsMediaProgress | None = None,
+) -> MassAudiobook:
+    """Translate AbsBook to Mass Book."""
+    title = abs_audiobook.media.metadata.title
+    # Per API doc title may be None.
+    if title is None:
+        title = "UNKNOWN TITLE"
+    subtitle = abs_audiobook.media.metadata.subtitle
+    if subtitle is not None or subtitle:
+        title += f" | {subtitle}"
+    mass_audiobook = MassAudiobook(
+        item_id=abs_audiobook.id_,
+        provider=lookup_key,
+        name=title,
+        duration=int(abs_audiobook.media.duration),
+        provider_mappings={
+            ProviderMapping(
+                item_id=abs_audiobook.id_,
+                provider_domain=domain,
+                provider_instance=instance_id,
+            )
+        },
+        publisher=abs_audiobook.media.metadata.publisher,
+    )
+    mass_audiobook.metadata.description = abs_audiobook.media.metadata.description
+    if abs_audiobook.media.metadata.language is not None:
+        mass_audiobook.metadata.languages = UniqueList([abs_audiobook.media.metadata.language])
+    mass_audiobook.metadata.release_date = abs_audiobook.media.metadata.published_date
+    if abs_audiobook.media.metadata.genres is not None:
+        mass_audiobook.metadata.genres = set(abs_audiobook.media.metadata.genres)
+
+    mass_audiobook.metadata.explicit = abs_audiobook.media.metadata.explicit
+
+    # cover
+    if token is not None:
+        api_url = f"/api/items/{abs_audiobook.id_}/cover?token={token}"
+        cover_url = f"{base_url}{api_url}"
+        mass_audiobook.metadata.images = UniqueList(
+            [MediaItemImage(type=ImageType.THUMB, path=cover_url, provider=lookup_key)]
+        )
+
+    # expanded version
+    if isinstance(abs_audiobook, AbsLibraryItemExpandedBook):
+        mass_audiobook.authors.set([x.name for x in abs_audiobook.media.metadata.authors])
+        mass_audiobook.narrators.set(abs_audiobook.media.metadata.narrators)
+        chapters = []
+        for idx, chapter in enumerate(abs_audiobook.media.chapters, 1):
+            chapters.append(
+                MediaItemChapter(
+                    position=idx,
+                    name=chapter.title,
+                    start=chapter.start,
+                    end=chapter.end,
+                )
+            )
+        mass_audiobook.metadata.chapters = chapters
+
+    elif isinstance(abs_audiobook, AbsLibraryItemMinifiedBook):
+        mass_audiobook.authors.set([abs_audiobook.media.metadata.author_name])
+        mass_audiobook.narrators.set([abs_audiobook.media.metadata.narrator_name])
+
+    if media_progress is not None:
+        mass_audiobook.resume_position_ms = int(media_progress.current_time * 1000)
+        mass_audiobook.fully_played = media_progress.is_finished
+
+    return mass_audiobook
index 89cd6179040c2af6c16955d241c513cad4424c7a..be17a6b0ae6c613a1a41bb435a0c8ce3ad30cbd3 100644 (file)
@@ -1,6 +1,7 @@
 # WARNING: this file is autogenerated!
 
 Brotli>=1.0.9
+aioaudiobookshelf==0.1.0
 aiodns>=3.0.0
 aiofiles==24.1.0
 aiohttp==3.11.11
index 27e6b4d901ce1c6837c622047631dbb734c1e489..466727c9db035c7c39a365524918006bdd9fd4e2 100644 (file)
@@ -8,6 +8,7 @@
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': 'e439648e08ade14e27d5de48fa97c88e',
         'media_type': 'artist',
         'name': 'Papa Roach',
@@ -28,6 +29,7 @@
       ]),
     ]),
     'favorite': False,
+    'is_playable': True,
     'item_id': '70b7288088b42d318f75dbcc41fd0091',
     'media_type': 'album',
     'metadata': dict({
@@ -94,6 +96,7 @@
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': '555b36f7d310d1b7405557a8775c6878',
         'media_type': 'artist',
         'name': 'Emmy the Great & Tim Wheeler',
       ]),
     ]),
     'favorite': False,
+    'is_playable': True,
     'item_id': '32ed6a0091733dcff57eae67010f3d4b',
     'media_type': 'album',
     'metadata': dict({
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': '[unknown]',
         'media_type': 'artist',
         'name': '[unknown]',
     'external_ids': list([
     ]),
     'favorite': False,
+    'is_playable': True,
     'item_id': '7c8d0bd55291c7fc0451d17ebef30017',
     'media_type': 'album',
     'metadata': dict({
       ]),
     ]),
     'favorite': False,
+    'is_playable': True,
     'item_id': 'dd954bbf54398e247d803186d3585b79',
     'media_type': 'artist',
     'metadata': dict({
       'external_ids': list([
       ]),
       'image': None,
+      'is_playable': True,
       'item_id': 'd42d74e134693184e7adc73106238e89',
       'media_type': 'album',
       'name': 'AM',
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': 'cc940aeb8a99149f159fe9794f136071',
         'media_type': 'artist',
         'name': 'Arctic Monkeys',
     'external_ids': list([
     ]),
     'favorite': False,
+    'is_playable': True,
     'item_id': 'da9c458e425584680765ddc3a89cbc0c',
     'media_type': 'track',
     'metadata': dict({
       'external_ids': list([
       ]),
       'image': None,
+      'is_playable': True,
       'item_id': '70b7288088b42d318f75dbcc41fd0091',
       'media_type': 'album',
       'name': 'Unknown Album (70b7288088b42d318f75dbcc41fd0091)',
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': '[unknown]',
         'media_type': 'artist',
         'name': '[unknown]',
     'external_ids': list([
     ]),
     'favorite': False,
+    'is_playable': True,
     'item_id': 'b5319fb11cde39fca2023184fcfa9862',
     'media_type': 'track',
     'metadata': dict({
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': '94875b0dd58cbf5245a135982133651a',
         'media_type': 'artist',
         'name': 'Dead Like Harry',
     'external_ids': list([
     ]),
     'favorite': False,
+    'is_playable': True,
     'item_id': '54918f75ee8f6c8b8dc5efd680644f29',
     'media_type': 'track',
     'metadata': dict({
       'external_ids': list([
       ]),
       'image': None,
+      'is_playable': True,
       'item_id': '32ed6a0091733dcff57eae67010f3d4b',
       'media_type': 'album',
       'name': 'This Is Christmas',
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': 'a0c459294295710546c81c20a8d9abfc',
         'media_type': 'artist',
         'name': 'Emmy the Great',
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': '1952db245ddef4e41dcd016475379190',
         'media_type': 'artist',
         'name': 'Tim Wheeler',
       ]),
     ]),
     'favorite': False,
+    'is_playable': True,
     'item_id': 'fb12a77f49616a9fc56a6fab2b94174c',
     'media_type': 'track',
     'metadata': dict({
index 4e86a9e3959286da1ef63f338079594c4c52984e..b3946ed50576e28bb65ce88bbfe50a002c73f032 100644 (file)
@@ -8,6 +8,7 @@
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': '91c3901ac465b9efc439e4be4270c2b6',
         'media_type': 'artist',
         'name': 'pornophonique',
@@ -20,6 +21,7 @@
     'external_ids': list([
     ]),
     'favorite': True,
+    'is_playable': True,
     'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
     'media_type': 'album',
     'metadata': dict({
@@ -86,6 +88,7 @@
         'external_ids': list([
         ]),
         'image': None,
+        'is_playable': True,
         'item_id': '91c3901ac465b9efc439e4be4270c2b6',
         'media_type': 'artist',
         'name': 'pornophonique',
     'external_ids': list([
     ]),
     'favorite': True,
+    'is_playable': True,
     'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
     'media_type': 'album',
     'metadata': dict({
     'external_ids': list([
     ]),
     'favorite': True,
+    'is_playable': True,
     'item_id': '37ec820ca7193e17040c98f7da7c4b51',
     'media_type': 'artist',
     'metadata': dict({
     'external_ids': list([
     ]),
     'favorite': True,
+    'is_playable': True,
     'item_id': '37ec820ca7193e17040c98f7da7c4b51',
     'media_type': 'artist',
     'metadata': dict({
     'external_ids': list([
     ]),
     'favorite': True,
+    'is_playable': True,
     'item_id': '100000002',
     'media_type': 'artist',
     'metadata': dict({
     'external_ids': list([
     ]),
     'favorite': True,
+    'is_playable': True,
     'item_id': '100000002',
     'media_type': 'artist',
     'metadata': dict({