music-assistant-server

65.8 KBPY
__init__.py
65.8 KB1,588 lines • python
1"""Audiobookshelf (abs) provider for Music Assistant."""
2
3from __future__ import annotations
4
5import asyncio
6import functools
7import itertools
8import time
9from collections.abc import AsyncGenerator, Callable, Coroutine, Sequence
10from contextlib import suppress
11from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
12
13import aioaudiobookshelf as aioabs
14from aioaudiobookshelf.client.items import LibraryItemExpandedBook as AbsLibraryItemExpandedBook
15from aioaudiobookshelf.client.items import (
16    LibraryItemExpandedPodcast as AbsLibraryItemExpandedPodcast,
17)
18from aioaudiobookshelf.exceptions import LoginError as AbsLoginError
19from aioaudiobookshelf.exceptions import RefreshTokenExpiredError
20from aioaudiobookshelf.schema.author import AuthorExpanded
21from aioaudiobookshelf.schema.calls_authors import (
22    AuthorWithItemsAndSeries as AbsAuthorWithItemsAndSeries,
23)
24from aioaudiobookshelf.schema.calls_series import SeriesWithProgress as AbsSeriesWithProgress
25from aioaudiobookshelf.schema.library import (
26    LibraryItemExpanded,
27    LibraryItemExpandedBook,
28    LibraryItemExpandedPodcast,
29    LibraryItemMinifiedPodcast,
30)
31from aioaudiobookshelf.schema.library import LibraryMediaType as AbsLibraryMediaType
32from aioaudiobookshelf.schema.shelf import (
33    SeriesShelf,
34    ShelfAuthors,
35    ShelfBook,
36    ShelfEpisode,
37    ShelfLibraryItemMinified,
38    ShelfPodcast,
39    ShelfSeries,
40)
41from aioaudiobookshelf.schema.shelf import ShelfId as AbsShelfId
42from aioaudiobookshelf.schema.shelf import ShelfType as AbsShelfType
43from aiohttp import web
44from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
45from music_assistant_models.enums import (
46    ConfigEntryType,
47    ContentType,
48    MediaType,
49    ProviderFeature,
50    StreamType,
51)
52from music_assistant_models.errors import LoginFailed, MediaNotFoundError
53from music_assistant_models.media_items import (
54    Audiobook,
55    AudioFormat,
56    BrowseFolder,
57    ItemMapping,
58    MediaItemType,
59    PodcastEpisode,
60    UniqueList,
61)
62from music_assistant_models.media_items.media_item import RecommendationFolder
63from music_assistant_models.streamdetails import MultiPartPath, StreamDetails
64
65from music_assistant.models.music_provider import MusicProvider
66from music_assistant.providers.audiobookshelf.parsers import (
67    parse_audiobook,
68    parse_podcast,
69    parse_podcast_episode,
70)
71
72from .constants import (
73    ABS_BROWSE_ITEMS_TO_PATH,
74    ABS_SHELF_ID_ICONS,
75    ABS_SHELF_ID_TRANSLATION_KEY,
76    AIOHTTP_TIMEOUT,
77    CACHE_CATEGORY_LIBRARIES,
78    CACHE_KEY_LIBRARIES,
79    CONF_API_TOKEN,
80    CONF_HIDE_EMPTY_PODCASTS,
81    CONF_OLD_TOKEN,
82    CONF_PASSWORD,
83    CONF_URL,
84    CONF_USERNAME,
85    CONF_VERIFY_SSL,
86    AbsBrowseItemsBookTranslationKey,
87    AbsBrowseItemsPodcastTranslationKey,
88    AbsBrowsePaths,
89)
90from .helpers import LibrariesHelper, LibraryHelper, ProgressGuard
91
92if TYPE_CHECKING:
93    from aioaudiobookshelf.schema.events_socket import LibraryItemRemoved
94    from aioaudiobookshelf.schema.media_progress import MediaProgress
95    from aioaudiobookshelf.schema.user import User
96    from music_assistant_models.media_items import Podcast
97    from music_assistant_models.provider import ProviderManifest
98
99    from music_assistant.mass import MusicAssistant
100    from music_assistant.models import ProviderInstanceType
101
102SUPPORTED_FEATURES = {
103    ProviderFeature.LIBRARY_PODCASTS,
104    ProviderFeature.LIBRARY_AUDIOBOOKS,
105    ProviderFeature.BROWSE,
106    ProviderFeature.RECOMMENDATIONS,
107}
108
109
110async def setup(
111    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
112) -> ProviderInstanceType:
113    """Initialize provider(instance) with given configuration."""
114    return Audiobookshelf(mass, manifest, config, SUPPORTED_FEATURES)
115
116
117async def get_config_entries(
118    mass: MusicAssistant,
119    instance_id: str | None = None,
120    action: str | None = None,
121    values: dict[str, ConfigValueType] | None = None,
122) -> tuple[ConfigEntry, ...]:
123    """
124    Return Config entries to setup this provider.
125
126    instance_id: id of an existing provider instance (None if new instance setup).
127    action: [optional] action key called from config entries UI.
128    values: the (intermediate) raw values for config entries sent with the action.
129    """
130    # ruff: noqa: ARG001
131    return (
132        ConfigEntry(
133            key="label",
134            type=ConfigEntryType.LABEL,
135            label="Please provide the address of your Audiobookshelf instance. To authenticate "
136            "you have two options: "
137            "a) Provide username AND password. Leave the API key empty. "
138            "b) Provide ONLY an API key.",
139        ),
140        ConfigEntry(
141            key=CONF_URL,
142            type=ConfigEntryType.STRING,
143            label="Server",
144            required=True,
145            description="The URL of the Audiobookshelf server to connect to. For example "
146            "https://abs.domain.tld/ or http://192.168.1.4:13378/",
147        ),
148        ConfigEntry(
149            key=CONF_USERNAME,
150            type=ConfigEntryType.STRING,
151            label="Username",
152            required=False,
153            description="The username to authenticate to the remote server.",
154        ),
155        ConfigEntry(
156            key=CONF_PASSWORD,
157            type=ConfigEntryType.SECURE_STRING,
158            label="Password",
159            required=False,
160            description="The password to authenticate to the remote server.",
161        ),
162        ConfigEntry(
163            key=CONF_API_TOKEN,
164            type=ConfigEntryType.SECURE_STRING,
165            label="API key _instead_ of user/ password. (ABS version >= 2.26)",
166            required=False,
167            description="Instead of using a username and password, "
168            "you may provide an API key (ABS version >= 2.26). "
169            "Please consult the docs.",
170        ),
171        ConfigEntry(
172            key=CONF_OLD_TOKEN,
173            type=ConfigEntryType.SECURE_STRING,
174            label="old token",
175            required=False,
176            hidden=True,
177        ),
178        ConfigEntry(
179            key=CONF_VERIFY_SSL,
180            type=ConfigEntryType.BOOLEAN,
181            label="Verify SSL",
182            required=False,
183            description="Whether or not to verify the certificate of SSL/TLS connections.",
184            advanced=True,
185            default_value=True,
186        ),
187        ConfigEntry(
188            key=CONF_HIDE_EMPTY_PODCASTS,
189            type=ConfigEntryType.BOOLEAN,
190            label="Hide empty podcasts.",
191            required=False,
192            description="This will skip podcasts with no episodes associated.",
193            advanced=True,
194            default_value=False,
195        ),
196    )
197
198
199R = TypeVar("R")
200P = ParamSpec("P")
201
202
203class Audiobookshelf(MusicProvider):
204    """Audiobookshelf MusicProvider."""
205
206    _on_unload_callbacks: list[Callable[[], None]]
207
208    @staticmethod
209    def handle_refresh_token(
210        method: Callable[P, Coroutine[Any, Any, R]],
211    ) -> Callable[P, Coroutine[Any, Any, R]]:
212        """Decorate a method to handle an expired refresh token by relogin."""
213
214        @functools.wraps(method)
215        async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
216            self = cast("Audiobookshelf", args[0])
217            try:
218                return await method(*args, **kwargs)
219            except RefreshTokenExpiredError:
220                self.logger.debug("Refresh token expired. Trying to renew.")
221                await self.reauthenticate()
222                return await method(*args, **kwargs)
223
224        return wrapper
225
226    async def handle_async_init(self) -> None:
227        """Pass config values to client and initialize."""
228        self._on_unload_callbacks: list[Callable[[], None]] = []
229        base_url = str(self.config.get_value(CONF_URL))
230        username = str(self.config.get_value(CONF_USERNAME))
231        password = str(self.config.get_value(CONF_PASSWORD))
232        token_old = self.config.get_value(CONF_OLD_TOKEN)
233        token_api = self.config.get_value(CONF_API_TOKEN)
234        verify_ssl = bool(self.config.get_value(CONF_VERIFY_SSL))
235        session_config = aioabs.SessionConfiguration(
236            session=self.mass.http_session,
237            url=base_url,
238            verify_ssl=verify_ssl,
239            logger=self.logger,
240            pagination_items_per_page=30,  # audible provider goes with 50 for pagination
241            timeout=AIOHTTP_TIMEOUT,
242        )
243        # If we are configured with a non-expiring API key or not.
244        self.is_token_user = False
245        try:
246            if token_api is not None or token_old is not None:
247                _token = token_api if token_api is not None else token_old
248                session_config.token = str(_token)
249                (
250                    self._client,
251                    self._client_socket,
252                ) = await aioabs.get_user_and_socket_client_by_token(session_config=session_config)
253                self.is_token_user = True
254            else:
255                self._client, self._client_socket = await aioabs.get_user_and_socket_client(
256                    session_config=session_config, username=username, password=password
257                )
258            await self._client_socket.init_client()
259        except AbsLoginError as exc:
260            raise LoginFailed(f"Login to abs instance at {base_url} failed.") from exc
261
262        if token_old is not None and token_api is None:
263            # Log Message that the old token won't work
264            _version = self._client.server_settings.version.split(".")
265            if len(_version) >= 2:
266                try:
267                    major, minor = int(_version[0]), int(_version[1])
268                except ValueError:
269                    major = minor = 0
270                if major >= 2 and minor >= 26:
271                    self.logger.warning(
272                        """
273
274######## Audiobookshelf API key change #############################################################
275
276Audiobookshelf introduced a new API key system in version 2.26 (JWT).
277You are still using a token configured with a previous version of Audiobookshelf,
278but you are running version %s. This will stop working in a future Audiobookshelf release.
279Please create a non-expiring API Key instead, and update your configuration accordingly.
280Refer to the documentation of Audiobookshelf, https://www.audiobookshelf.org/guides/api-keys/
281and of Music Assistant https://www.music-assistant.io/music-providers/audiobookshelf/
282for more details.
283
284""",
285                        self._client.server_settings.version,
286                    )
287
288        cached_libraries = await self.mass.cache.get(
289            key=CACHE_KEY_LIBRARIES,
290            provider=self.instance_id,
291            category=CACHE_CATEGORY_LIBRARIES,
292            default=None,
293        )
294        if cached_libraries is None:
295            self.libraries = LibrariesHelper()
296            # We need the library ids for recommendations. If the cache got cleared e.g. by a db
297            # migration, we might end up with empty library helpers on a configured provider. Note,
298            # that the lib item ids are not synced, still only on full provider sync, instead the
299            # sets are empty. Full sync is expensive.
300            # See warning in browse_lib_podcasts / _browse_books
301            libraries = await self._client.get_all_libraries()
302            for library in libraries:
303                if library.media_type == AbsLibraryMediaType.BOOK:
304                    self.libraries.audiobooks[library.id_] = LibraryHelper(name=library.name)
305                elif library.media_type == AbsLibraryMediaType.PODCAST:
306                    self.libraries.podcasts[library.id_] = LibraryHelper(name=library.name)
307        else:
308            self.libraries = LibrariesHelper.from_dict(cached_libraries)
309
310        # set socket callbacks
311        self._client_socket.set_item_callbacks(
312            on_item_added=self._socket_abs_item_changed,
313            on_item_updated=self._socket_abs_item_changed,
314            on_item_removed=self._socket_abs_item_removed,
315            on_items_added=self._socket_abs_item_changed,
316            on_items_updated=self._socket_abs_item_changed,
317        )
318
319        self._client_socket.set_user_callbacks(
320            on_user_item_progress_updated=self._socket_abs_user_item_progress_updated,
321        )
322
323        self._client_socket.set_refresh_token_expired_callback(
324            on_refresh_token_expired=self._socket_abs_refresh_token_expired
325        )
326
327        # progress guard
328        self.progress_guard = ProgressGuard()
329
330        # safe guard reauthentication
331        self.reauthenticate_lock = asyncio.Lock()
332        self.reauthenticate_last = 0.0
333
334        # register dynamic stream route for audiobook parts
335        self._on_unload_callbacks.append(
336            self.mass.streams.register_dynamic_route(
337                f"/{self.instance_id}_part_stream", self._handle_audiobook_part_request
338            )
339        )
340        self._on_unload_callbacks.append(
341            self.mass.streams.register_dynamic_route(
342                f"/{self.instance_id}_episode_stream", self._handle_episode_request
343            )
344        )
345
346    @handle_refresh_token
347    async def unload(self, is_removed: bool = False) -> None:
348        """
349        Handle unload/close of the provider.
350
351        Called when provider is deregistered (e.g. MA exiting or config reloading).
352        is_removed will be set to True when the provider is removed from the configuration.
353        """
354        await self._client.logout()
355        await self._client_socket.logout()
356        for callback in self._on_unload_callbacks:
357            callback()
358
359    @property
360    def is_streaming_provider(self) -> bool:
361        """Return True if the provider is a streaming provider."""
362        # For streaming providers return True here but for local file based providers return False.
363        return False
364
365    @handle_refresh_token
366    async def sync_library(self, media_type: MediaType) -> None:
367        """Obtain audiobook library ids and podcast library ids."""
368        libraries = await self._client.get_all_libraries()
369        if len(libraries) == 0:
370            self._log_no_libraries()
371        for library in libraries:
372            if library.media_type == AbsLibraryMediaType.BOOK and media_type == MediaType.AUDIOBOOK:
373                self.libraries.audiobooks[library.id_] = LibraryHelper(name=library.name)
374            elif (
375                library.media_type == AbsLibraryMediaType.PODCAST
376                and media_type == MediaType.PODCAST
377            ):
378                self.libraries.podcasts[library.id_] = LibraryHelper(name=library.name)
379        await super().sync_library(media_type)
380        await self._cache_set_helper_libraries()
381
382        # update playlog
383        user = await self._client.get_my_user()
384        await self._set_playlog_from_user(user)
385
386    async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
387        """Retrieve library/subscribed podcasts from the provider.
388
389        Minified podcast information is enough.
390        """
391        for pod_lib_id in self.libraries.podcasts:
392            async for response in self._client.get_library_items(library_id=pod_lib_id):
393                if not response.results:
394                    break
395                podcast_ids = [x.id_ for x in response.results]
396                # store uuids
397                self.libraries.podcasts[pod_lib_id].item_ids.update(podcast_ids)
398                for podcast_minified in response.results:
399                    assert isinstance(podcast_minified, LibraryItemMinifiedPodcast)
400                    mass_podcast = parse_podcast(
401                        abs_podcast=podcast_minified,
402                        instance_id=self.instance_id,
403                        domain=self.domain,
404                        token=self._client.token,
405                        base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
406                    )
407                    if (
408                        bool(self.config.get_value(CONF_HIDE_EMPTY_PODCASTS))
409                        and mass_podcast.total_episodes == 0
410                    ):
411                        continue
412                    yield mass_podcast
413
414    @handle_refresh_token
415    async def _get_abs_expanded_podcast(
416        self, prov_podcast_id: str
417    ) -> AbsLibraryItemExpandedPodcast:
418        abs_podcast = await self._client.get_library_item_podcast(
419            podcast_id=prov_podcast_id, expanded=True
420        )
421        assert isinstance(abs_podcast, AbsLibraryItemExpandedPodcast)
422
423        return abs_podcast
424
425    @handle_refresh_token
426    async def get_podcast(self, prov_podcast_id: str) -> Podcast:
427        """Get single podcast."""
428        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
429        return parse_podcast(
430            abs_podcast=abs_podcast,
431            instance_id=self.instance_id,
432            domain=self.domain,
433            token=self._client.token,
434            base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
435        )
436
437    async def get_podcast_episodes(
438        self, prov_podcast_id: str
439    ) -> AsyncGenerator[PodcastEpisode, None]:
440        """Get all podcast episodes of podcast.
441
442        Adds progress information.
443        """
444        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
445        episode_cnt = 1
446        # the user has the progress of all media items
447        # so we use a single api call here to obtain possibly many
448        # progresses for episodes
449        user = await self._client.get_my_user()
450        abs_progresses = {
451            x.episode_id: x
452            for x in user.media_progress
453            if x.episode_id is not None and x.library_item_id == prov_podcast_id
454        }
455        for abs_episode in abs_podcast.media.episodes:
456            progress = abs_progresses.get(abs_episode.id_, None)
457            mass_episode = parse_podcast_episode(
458                episode=abs_episode,
459                prov_podcast_id=prov_podcast_id,
460                fallback_episode_cnt=episode_cnt,
461                instance_id=self.instance_id,
462                domain=self.domain,
463                token=self._client.token,
464                base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
465                media_progress=progress,
466            )
467            yield mass_episode
468            episode_cnt += 1
469
470    @handle_refresh_token
471    async def get_podcast_episode(
472        self, prov_episode_id: str, add_progress: bool = True
473    ) -> PodcastEpisode:
474        """Get single podcast episode."""
475        prov_podcast_id, e_id = prov_episode_id.split(" ")
476        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
477        episode_cnt = 1
478        for abs_episode in abs_podcast.media.episodes:
479            if abs_episode.id_ == e_id:
480                progress = None
481                if add_progress:
482                    progress = await self._client.get_my_media_progress(
483                        item_id=prov_podcast_id, episode_id=abs_episode.id_
484                    )
485                return parse_podcast_episode(
486                    episode=abs_episode,
487                    prov_podcast_id=prov_podcast_id,
488                    fallback_episode_cnt=episode_cnt,
489                    instance_id=self.instance_id,
490                    domain=self.domain,
491                    token=self._client.token,
492                    base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
493                    media_progress=progress,
494                )
495
496            episode_cnt += 1
497        raise MediaNotFoundError("Episode not found")
498
499    async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]:
500        """Get Audiobook libraries.
501
502        Need expanded version for chapters.
503        """
504        for book_lib_id in self.libraries.audiobooks:
505            async for response in self._client.get_library_items(library_id=book_lib_id):
506                if not response.results:
507                    break
508                book_ids = [x.id_ for x in response.results]
509                # store uuids
510                self.libraries.audiobooks[book_lib_id].item_ids.update(book_ids)
511                # use expanded version for chapters/ caching.
512                books_expanded = await self._client.get_library_item_batch_book(item_ids=book_ids)
513                for book_expanded in books_expanded:
514                    # If the book has no audiofiles, we skip -> ebook only.
515                    if len(book_expanded.media.tracks) == 0:
516                        continue
517                    mass_audiobook = parse_audiobook(
518                        abs_audiobook=book_expanded,
519                        instance_id=self.instance_id,
520                        domain=self.domain,
521                        token=self._client.token,
522                        base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
523                    )
524                    yield mass_audiobook
525
526    @handle_refresh_token
527    async def _get_abs_expanded_audiobook(
528        self, prov_audiobook_id: str
529    ) -> AbsLibraryItemExpandedBook:
530        abs_audiobook = await self._client.get_library_item_book(
531            book_id=prov_audiobook_id, expanded=True
532        )
533        assert isinstance(abs_audiobook, AbsLibraryItemExpandedBook)
534
535        return abs_audiobook
536
537    @handle_refresh_token
538    async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
539        """Get a single audiobook.
540
541        Progress is added here.
542        """
543        progress = await self._client.get_my_media_progress(item_id=prov_audiobook_id)
544        abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=prov_audiobook_id)
545        return parse_audiobook(
546            abs_audiobook=abs_audiobook,
547            instance_id=self.instance_id,
548            domain=self.domain,
549            token=self._client.token,
550            base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
551            media_progress=progress,
552        )
553
554    async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
555        """Get stream of item."""
556        if media_type == MediaType.PODCAST_EPISODE:
557            return await self._get_stream_details_episode(item_id)
558        if media_type == MediaType.AUDIOBOOK:
559            abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=item_id)
560            return await self._get_stream_details_audiobook(abs_audiobook)
561        raise MediaNotFoundError("Stream unknown")
562
563    async def _get_stream_details_audiobook(
564        self, abs_audiobook: AbsLibraryItemExpandedBook
565    ) -> StreamDetails:
566        """Streamdetails audiobook.
567
568        We always use a custom stream type, also for single file, such
569        that we can handle an ffmpeg error and refresh our tokens.
570        """
571        tracks = abs_audiobook.media.tracks
572        if len(tracks) == 0:
573            raise MediaNotFoundError("Stream not found")
574
575        content_type = ContentType.UNKNOWN
576        if abs_audiobook.media.tracks[0].metadata is not None:
577            content_type = ContentType.try_parse(abs_audiobook.media.tracks[0].metadata.ext)
578
579        file_parts: list[MultiPartPath] = []
580        abs_base_url = str(self.config.get_value(CONF_URL))
581        if self.is_token_user:
582            self.logger.debug("Token User - Streams are direct.")
583        for idx, track in enumerate(tracks):
584            if self.is_token_user:
585                # an api key is long-lived
586                stream_url = f"{abs_base_url}{track.content_url}?token={self._client.token}"
587            else:
588                # to ensure token is always valid, we create a dynamic url
589                # this ensures that we always get a fresh token on each part
590                # without having to deal with a custom stream etc.
591                # we also use this for the first part, otherwise we can't seek
592                stream_url = (
593                    f"{self.mass.streams.base_url}/{self.instance_id}_part_stream?"
594                    f"audiobook_id={abs_audiobook.id_}&part_id={idx}"
595                )
596            file_parts.append(MultiPartPath(path=stream_url, duration=track.duration))
597
598        return StreamDetails(
599            provider=self.instance_id,
600            item_id=abs_audiobook.id_,
601            audio_format=AudioFormat(content_type=content_type),
602            media_type=MediaType.AUDIOBOOK,
603            stream_type=StreamType.HTTP,
604            duration=int(abs_audiobook.media.duration),
605            path=file_parts,
606            can_seek=True,
607            allow_seek=True,
608        )
609
610    async def _get_stream_details_episode(self, podcast_id: str) -> StreamDetails:
611        """Streamdetails of a podcast episode.
612
613        There are no multi-file podcasts in abs, but we use a custom
614        stream to handle possible ffmpeg errors.
615        """
616        abs_podcast_id, abs_episode_id = podcast_id.split(" ")
617        abs_episode = None
618
619        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=abs_podcast_id)
620        for abs_episode in abs_podcast.media.episodes:
621            if abs_episode.id_ == abs_episode_id:
622                break
623        if abs_episode is None:
624            raise MediaNotFoundError("Stream not found")
625        content_type = ContentType.UNKNOWN
626        if abs_episode.audio_track.metadata is not None:
627            content_type = ContentType.try_parse(abs_episode.audio_track.metadata.ext)
628
629        if self.is_token_user:
630            self.logger.debug("Token User - Stream is direct.")
631            # long lived API token, no need for detour
632            abs_base_url = str(self.config.get_value(CONF_URL))
633            stream_url = (
634                f"{abs_base_url}{abs_episode.audio_track.content_url}?token={self._client.token}"
635            )
636        else:
637            stream_url = (
638                f"{self.mass.streams.base_url}/{self.instance_id}_episode_stream?"
639                f"podcast_id={abs_podcast.id_}&episode_id={abs_episode.id_}"
640            )
641
642        return StreamDetails(
643            provider=self.instance_id,
644            item_id=podcast_id,
645            audio_format=AudioFormat(
646                content_type=content_type,
647            ),
648            media_type=MediaType.PODCAST_EPISODE,
649            stream_type=StreamType.HTTP,
650            can_seek=True,
651            allow_seek=True,
652            path=stream_url,
653        )
654
655    async def _handle_audiobook_part_request(self, request: web.Request) -> web.Response:
656        """
657        Handle dynamic audiobook part stream request.
658
659        We redirect to the actual stream url with token.
660        This is done because the token might expire, so we need to
661        generate a fresh url on each part.
662        """
663        if not (audiobook_id := request.query.get("audiobook_id")):
664            return web.Response(status=400, text="Missing audiobook_id")
665        if not (part_id := request.query.get("part_id")):
666            return web.Response(status=400, text="Missing part_id")
667        abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=audiobook_id)
668        part_id = int(part_id)  # type: ignore[assignment]
669        try:
670            part_track = abs_audiobook.media.tracks[part_id]
671        except IndexError:
672            return web.Response(status=404, text="Part not found")
673
674        base_url = str(self.config.get_value(CONF_URL))
675        stream_url = f"{base_url}{part_track.content_url}?token={self._client.token}"
676        # redirect to the actual stream url
677        raise web.HTTPFound(location=stream_url)
678
679    async def _handle_episode_request(self, request: web.Request) -> web.Response:
680        """Podcast episode request.
681
682        For a podcast episode, we only have a single file, but the token might be expired should
683        user try to seek an episode.
684        """
685        if not (abs_podcast_id := request.query.get("podcast_id")):
686            return web.Response(status=400, text="Missing podcast_id")
687        if not (abs_episode_id := request.query.get("episode_id")):
688            return web.Response(status=400, text="Missing episode_id")
689        abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=abs_podcast_id)
690        abs_episode = None
691        for abs_episode in abs_podcast.media.episodes:
692            if abs_episode.id_ == abs_episode_id:
693                break
694        if abs_episode is None:
695            return web.Response(status=400, text="Stream not found")
696
697        base_url = str(self.config.get_value(CONF_URL))
698        stream_url = f"{base_url}{abs_episode.audio_track.content_url}?token={self._client.token}"
699
700        # redirect to the actual stream url
701        raise web.HTTPFound(location=stream_url)
702
703    @handle_refresh_token
704    async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:
705        """Return finished:bool, position_ms: int."""
706        progress: None | MediaProgress = None
707        if media_type == MediaType.PODCAST_EPISODE:
708            abs_podcast_id, abs_episode_id = item_id.split(" ")
709            progress = await self._client.get_my_media_progress(
710                item_id=abs_podcast_id, episode_id=abs_episode_id
711            )
712
713        if media_type == MediaType.AUDIOBOOK:
714            progress = await self._client.get_my_media_progress(item_id=item_id)
715
716        if progress is not None and progress.current_time is not None:
717            self.logger.debug("Resume position: obtained.")
718            return progress.is_finished, int(progress.current_time * 1000)
719
720        return False, 0
721
722    @handle_refresh_token
723    async def recommendations(self) -> list[RecommendationFolder]:
724        """Get recommendations."""
725        # We have to avoid "flooding" the home page, which becomes especially troublesome if users
726        # have multiple libraries. Instead we collect per ShelfId, and make sure, that we always get
727        # roughly the same amount of items per row, no matter the amount of libraries
728        # List of list (one list per lib) here, such that we can pick the items per lib later.
729        items_by_shelf_id: dict[AbsShelfId, list[list[MediaItemType | BrowseFolder]]] = {}
730
731        all_libraries = {**self.libraries.audiobooks, **self.libraries.podcasts}
732        max_items_per_row = 20
733        num_libraries = len(all_libraries)
734
735        if num_libraries == 0:
736            self._log_no_libraries()
737            return []
738
739        limit_items_per_lib = max_items_per_row // num_libraries
740        limit_items_per_lib = 1 if limit_items_per_lib == 0 else limit_items_per_lib
741
742        for library_id in all_libraries:
743            shelves = await self._client.get_library_personalized_view(
744                library_id=library_id, limit=limit_items_per_lib
745            )
746            await self._recommendations_iter_shelves(shelves, library_id, items_by_shelf_id)
747
748        folders: list[RecommendationFolder] = []
749        for shelf_id, item_lists in items_by_shelf_id.items():
750            # we have something like [[A, B], [C, D, E], [F]]
751            # and want [A, C, F, B, D, E]
752            recommendation_items = [
753                x
754                for x in itertools.chain.from_iterable(itertools.zip_longest(*item_lists))
755                if x is not None
756            ][:max_items_per_row]
757
758            # shelf ids follow pattern:
759            # recently-added
760            # newest-episodes
761            # etc
762            name = f"{shelf_id.capitalize().replace('-', ' ')}"
763            if ABS_SHELF_ID_TRANSLATION_KEY.get(shelf_id):
764                name = ""  # use translation key if available
765            folders.append(
766                RecommendationFolder(
767                    item_id=f"{shelf_id}",
768                    name=name,
769                    icon=ABS_SHELF_ID_ICONS.get(shelf_id),
770                    translation_key=ABS_SHELF_ID_TRANSLATION_KEY.get(shelf_id),
771                    items=UniqueList(recommendation_items),
772                    provider=self.instance_id,
773                )
774            )
775
776        # Browse "recommendation" for convenience. If the user has
777        # multiple audiobook libraries, we return a listing of them.
778        # If there is only a single audiobook library, we add the folders
779        # from _browse_lib_audiobooks, i.e. Authors, Narrators etc.
780        # Podcast libs do not have filter folders, so always the root folders.
781        browse_items: list[MediaItemType | BrowseFolder] = []
782        translation_key = "libraries"
783        if len(self.libraries.audiobooks) <= 1:
784            if len(self.libraries.podcasts) == 0:
785                translation_key = "library"
786
787            # audiobooklibs are first, and we have at max 1 audiobook lib
788            _browse_root = self._browse_root(append_mediatype_suffix=False)
789            if len(self.libraries.audiobooks) == 0:
790                browse_items.extend(_browse_root)
791            else:
792                assert isinstance(_browse_root[0], BrowseFolder)
793                _path = _browse_root[0].path
794                browse_items.extend(self._browse_lib_audiobooks(current_path=_path))
795                # add podcast roots
796                browse_items.extend(_browse_root[1:])
797        else:
798            browse_items = list(self._browse_root())
799
800        folders.append(
801            RecommendationFolder(
802                item_id="browse",
803                name="",  # use translation key
804                icon="mdi-bookshelf",
805                translation_key=translation_key,
806                items=UniqueList(browse_items),
807                provider=self.instance_id,
808            )
809        )
810
811        return folders
812
813    async def _recommendations_iter_shelves(
814        self,
815        shelves: list[ShelfBook | ShelfPodcast | ShelfAuthors | ShelfEpisode | ShelfSeries],
816        library_id: str,
817        items_by_shelf_id: dict[AbsShelfId, list[list[MediaItemType | BrowseFolder]]],
818    ) -> None:
819        for shelf in shelves:
820            media_type: MediaType
821            match shelf.type_:
822                case AbsShelfType.PODCAST:
823                    media_type = MediaType.PODCAST
824                case AbsShelfType.EPISODE:
825                    media_type = MediaType.PODCAST_EPISODE
826                case AbsShelfType.BOOK:
827                    media_type = MediaType.AUDIOBOOK
828                case AbsShelfType.SERIES | AbsShelfType.AUTHORS:
829                    media_type = MediaType.FOLDER
830                case _:
831                    # this would be authors, currently
832                    continue
833
834            items: list[MediaItemType | BrowseFolder] = []
835            # Recently added is the _only_ case, where we get a full podcast
836            # We have a podcast object with only the episodes matching the
837            # shelf.id_ otherwise.
838            match shelf.id_:
839                case (
840                    AbsShelfId.RECENTLY_ADDED
841                    | AbsShelfId.LISTEN_AGAIN
842                    | AbsShelfId.DISCOVER
843                    | AbsShelfId.NEWEST_EPISODES
844                    | AbsShelfId.CONTINUE_LISTENING
845                ):
846                    for entity in shelf.entities:
847                        assert isinstance(entity, ShelfLibraryItemMinified)
848                        item: MediaItemType | None = None
849                        if media_type in [MediaType.PODCAST, MediaType.AUDIOBOOK]:
850                            item = await self.mass.music.get_library_item_by_prov_id(
851                                media_type=media_type,
852                                provider_instance_id_or_domain=self.instance_id,
853                                item_id=entity.id_,
854                            )
855                        elif media_type == MediaType.PODCAST_EPISODE:
856                            podcast_id = entity.id_
857                            if entity.recent_episode is None:
858                                continue
859                            # we only have a PodcastEpisode here, with limited information
860                            item = parse_podcast_episode(
861                                episode=entity.recent_episode,
862                                prov_podcast_id=podcast_id,
863                                instance_id=self.instance_id,
864                                domain=self.domain,
865                                token=self._client.token,
866                                base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
867                            )
868                        if item is not None:
869                            items.append(item)
870                case AbsShelfId.RECENT_SERIES | AbsShelfId.CONTINUE_SERIES:
871                    # We jump into a browse folder here if we have SeriesShelf, set path up as if
872                    # browse function used.
873                    if isinstance(shelf, ShelfSeries):
874                        for entity in shelf.entities:
875                            assert isinstance(entity, SeriesShelf)
876                            if len(entity.books) == 0:
877                                continue
878                            path = (
879                                f"{self.instance_id}://"
880                                f"{AbsBrowsePaths.LIBRARIES_BOOK} {library_id}/"
881                                f"{AbsBrowsePaths.SERIES}/{entity.id_}"
882                            )
883                            items.append(
884                                BrowseFolder(
885                                    item_id=entity.id_,
886                                    name=entity.name,
887                                    provider=self.instance_id,
888                                    path=path,
889                                )
890                            )
891                    elif isinstance(shelf, ShelfBook) and media_type == MediaType.AUDIOBOOK:
892                        # Single books, must be audiobooks
893                        for entity in shelf.entities:
894                            item = await self.mass.music.get_library_item_by_prov_id(
895                                media_type=media_type,
896                                provider_instance_id_or_domain=self.instance_id,
897                                item_id=entity.id_,
898                            )
899                            if item is not None:
900                                items.append(item)
901                case AbsShelfId.NEWEST_AUTHORS:
902                    # same as for series, use a folder
903                    for entity in shelf.entities:
904                        assert isinstance(entity, AuthorExpanded)
905                        if entity.num_books == 0:
906                            continue
907                        path = (
908                            f"{self.instance_id}://"
909                            f"{AbsBrowsePaths.LIBRARIES_BOOK} {library_id}/"
910                            f"{AbsBrowsePaths.AUTHORS}/{entity.id_}"
911                        )
912                        items.append(
913                            BrowseFolder(
914                                item_id=entity.id_,
915                                name=entity.name,
916                                provider=self.instance_id,
917                                path=path,
918                            )
919                        )
920            if not items:
921                continue
922
923            # add collected items
924            assert isinstance(shelf.id_, AbsShelfId)
925            items_collected = items_by_shelf_id.get(shelf.id_, [])
926            items_collected.append(items)
927            items_by_shelf_id[shelf.id_] = items_collected
928
929    @handle_refresh_token
930    async def on_played(
931        self,
932        media_type: MediaType,
933        prov_item_id: str,
934        fully_played: bool,
935        position: int,
936        media_item: MediaItemType,
937        is_playing: bool = False,
938    ) -> None:
939        """Update progress in Audiobookshelf.
940
941        In our case media_type may have 3 values:
942            - PODCAST
943            - PODCAST_EPISODE
944            - AUDIOBOOK
945        We ignore PODCAST (function is called on adding a podcast with position=None)
946
947        """
948        if media_type == MediaType.PODCAST_EPISODE:
949            abs_podcast_id, abs_episode_id = prov_item_id.split(" ")
950
951            # guard, see progress guard class docstrings for explanation
952            if not self.progress_guard.guard_ok_mass(
953                item_id=abs_podcast_id, episode_id=abs_episode_id
954            ):
955                return
956            self.progress_guard.add_progress(item_id=abs_podcast_id, episode_id=abs_episode_id)
957
958            if media_item is None or not isinstance(media_item, PodcastEpisode):
959                return
960
961            if position == 0 and not fully_played:
962                # marked unplayed
963                mp = await self._client.get_my_media_progress(
964                    item_id=abs_podcast_id, episode_id=abs_episode_id
965                )
966                if mp is not None:
967                    await self._client.remove_my_media_progress(media_progress_id=mp.id_)
968                    self.logger.debug(f"Removed media progress of {media_type.value}.")
969                    return
970
971            duration = media_item.duration
972            self.logger.debug(
973                f"Updating media progress of {media_type.value}, title {media_item.name}."
974            )
975            await self._client.update_my_media_progress(
976                item_id=abs_podcast_id,
977                episode_id=abs_episode_id,
978                duration_seconds=duration,
979                progress_seconds=position,
980                is_finished=fully_played,
981            )
982
983        if media_type == MediaType.AUDIOBOOK:
984            # guard, see progress guard class docstrings for explanation
985            if not self.progress_guard.guard_ok_mass(item_id=prov_item_id):
986                return
987            self.progress_guard.add_progress(item_id=prov_item_id)
988
989            if media_item is None or not isinstance(media_item, Audiobook):
990                return
991
992            if position == 0 and not fully_played:
993                # marked unplayed
994                mp = await self._client.get_my_media_progress(item_id=prov_item_id)
995                if mp is not None:
996                    await self._client.remove_my_media_progress(media_progress_id=mp.id_)
997                    self.logger.debug(f"Removed media progress of {media_type.value}.")
998                return
999
1000            duration = media_item.duration
1001            self.logger.debug(f"Updating {media_type.value} named {media_item.name} progress")
1002            await self._client.update_my_media_progress(
1003                item_id=prov_item_id,
1004                duration_seconds=duration,
1005                progress_seconds=position,
1006                is_finished=fully_played,
1007            )
1008
1009    @handle_refresh_token
1010    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
1011        """Browse for audiobookshelf.
1012
1013        Generates this view:
1014        Library_Name_A (Audiobooks)
1015            Audiobooks
1016                Audiobook_1
1017                Audiobook_2
1018            Series
1019                Series_1
1020                    Audiobook_1
1021                    Audiobook_2
1022                Series_2
1023                    Audiobook_3
1024                    Audiobook_4
1025            Collections
1026                Collection_1
1027                    Audiobook_1
1028                    Audiobook_2
1029                Collection_2
1030                    Audiobook_3
1031                    Audiobook_4
1032            Authors
1033                Author_1
1034                    Series_1
1035                    Audiobook_1
1036                    Audiobook_2
1037                Author_2
1038                    Audiobook_3
1039        Library_Name_B (Podcasts)
1040            Podcast_1
1041            Podcast_2
1042        """
1043        item_path = path.split("://", 1)[1]
1044        if not item_path:
1045            return self._browse_root()
1046        sub_path = item_path.split("/")
1047        lib_key, lib_id = sub_path[0].split(" ")
1048        if len(sub_path) == 1:
1049            if lib_key == AbsBrowsePaths.LIBRARIES_PODCAST:
1050                return await self._browse_lib_podcasts(library_id=lib_id)
1051            return self._browse_lib_audiobooks(current_path=path)
1052        if len(sub_path) == 2:
1053            item_key = sub_path[1]
1054            match item_key:
1055                case AbsBrowsePaths.AUTHORS:
1056                    return await self._browse_authors(current_path=path, library_id=lib_id)
1057                case AbsBrowsePaths.NARRATORS:
1058                    return await self._browse_narrators(current_path=path, library_id=lib_id)
1059                case AbsBrowsePaths.SERIES:
1060                    return await self._browse_series(current_path=path, library_id=lib_id)
1061                case AbsBrowsePaths.COLLECTIONS:
1062                    return await self._browse_collections(current_path=path, library_id=lib_id)
1063                case AbsBrowsePaths.AUDIOBOOKS:
1064                    return await self._browse_books(library_id=lib_id)
1065        elif len(sub_path) == 3:
1066            item_key, item_id = sub_path[1:3]
1067            match item_key:
1068                case AbsBrowsePaths.AUTHORS:
1069                    return await self._browse_author_books(current_path=path, author_id=item_id)
1070                case AbsBrowsePaths.NARRATORS:
1071                    return await self._browse_narrator_books(
1072                        library_id=lib_id, narrator_filter_str=item_id
1073                    )
1074                case AbsBrowsePaths.SERIES:
1075                    return await self._browse_series_books(series_id=item_id)
1076                case AbsBrowsePaths.COLLECTIONS:
1077                    return await self._browse_collection_books(collection_id=item_id)
1078        elif len(sub_path) == 4:
1079            # series within author
1080            series_id = sub_path[3]
1081            return await self._browse_series_books(series_id=series_id)
1082        return []
1083
1084    def _browse_root(self, append_mediatype_suffix: bool = True) -> Sequence[BrowseFolder]:
1085        items = []
1086
1087        def _get_folder(
1088            path: str, lib_id: str, lib_name: str, translation_key: str | None = None
1089        ) -> BrowseFolder:
1090            return BrowseFolder(
1091                item_id=lib_id,
1092                name=lib_name,
1093                translation_key=translation_key,  # if given, <name>: <translation> in frontend
1094                provider=self.instance_id,
1095                path=f"{self.instance_id}://{path}",
1096            )
1097
1098        if len(self.libraries.audiobooks) == 0 and len(self.libraries.podcasts) == 0:
1099            self._log_no_libraries()
1100            return []
1101
1102        translation_key: str | None
1103        for lib_id, lib in self.libraries.audiobooks.items():
1104            path = f"{AbsBrowsePaths.LIBRARIES_BOOK} {lib_id}"
1105            translation_key = None
1106            if append_mediatype_suffix:
1107                translation_key = AbsBrowseItemsBookTranslationKey.AUDIOBOOKS
1108            items.append(
1109                _get_folder(path, lib_id, lib_name=lib.name, translation_key=translation_key)
1110            )
1111        for lib_id, lib in self.libraries.podcasts.items():
1112            path = f"{AbsBrowsePaths.LIBRARIES_PODCAST} {lib_id}"
1113            translation_key = None
1114            if append_mediatype_suffix:
1115                translation_key = AbsBrowseItemsPodcastTranslationKey.PODCASTS
1116            items.append(
1117                _get_folder(path, lib_id, lib_name=lib.name, translation_key=translation_key)
1118            )
1119        return items
1120
1121    async def _browse_lib_podcasts(self, library_id: str) -> list[MediaItemType]:
1122        """No sub categories for podcasts."""
1123        if len(self.libraries.podcasts[library_id].item_ids) == 0:
1124            self._log_no_helper_item_ids()
1125        items = []
1126        for podcast_id in self.libraries.podcasts[library_id].item_ids:
1127            mass_item = await self.mass.music.get_library_item_by_prov_id(
1128                media_type=MediaType.PODCAST,
1129                item_id=podcast_id,
1130                provider_instance_id_or_domain=self.instance_id,
1131            )
1132            if mass_item is not None:
1133                items.append(mass_item)
1134        return sorted(items, key=lambda x: x.name)
1135
1136    def _browse_lib_audiobooks(self, current_path: str) -> Sequence[BrowseFolder]:
1137        items = []
1138        for translation_key in AbsBrowseItemsBookTranslationKey:
1139            path = current_path + "/" + ABS_BROWSE_ITEMS_TO_PATH[translation_key]
1140            items.append(
1141                BrowseFolder(
1142                    item_id=translation_key.lower(),
1143                    name="",  # use translation key
1144                    translation_key=translation_key,
1145                    provider=self.instance_id,
1146                    path=path,
1147                )
1148            )
1149        return items
1150
1151    async def _browse_authors(self, current_path: str, library_id: str) -> Sequence[BrowseFolder]:
1152        abs_authors = await self._client.get_library_authors(library_id=library_id)
1153        items = []
1154        for author in abs_authors:
1155            path = f"{current_path}/{author.id_}"
1156            items.append(
1157                BrowseFolder(
1158                    item_id=author.id_,
1159                    name=author.name,
1160                    provider=self.instance_id,
1161                    path=path,
1162                )
1163            )
1164
1165        return sorted(items, key=lambda x: x.name)
1166
1167    async def _browse_narrators(self, current_path: str, library_id: str) -> Sequence[BrowseFolder]:
1168        abs_narrators = await self._client.get_library_narrators(library_id=library_id)
1169        items = []
1170        for narrator in abs_narrators:
1171            path = f"{current_path}/{narrator.id_}"
1172            items.append(
1173                BrowseFolder(
1174                    item_id=narrator.id_,
1175                    name=narrator.name,
1176                    provider=self.instance_id,
1177                    path=path,
1178                )
1179            )
1180
1181        return sorted(items, key=lambda x: x.name)
1182
1183    async def _browse_series(self, current_path: str, library_id: str) -> Sequence[BrowseFolder]:
1184        items = []
1185        async for response in self._client.get_library_series(library_id=library_id):
1186            if not response.results:
1187                break
1188            for abs_series in response.results:
1189                path = f"{current_path}/{abs_series.id_}"
1190                items.append(
1191                    BrowseFolder(
1192                        item_id=abs_series.id_,
1193                        name=abs_series.name,
1194                        provider=self.instance_id,
1195                        path=path,
1196                    )
1197                )
1198
1199        return sorted(items, key=lambda x: x.name)
1200
1201    async def _browse_collections(
1202        self, current_path: str, library_id: str
1203    ) -> Sequence[BrowseFolder]:
1204        items = []
1205        async for response in self._client.get_library_collections(library_id=library_id):
1206            if not response.results:
1207                break
1208            for abs_collection in response.results:
1209                path = f"{current_path}/{abs_collection.id_}"
1210                items.append(
1211                    BrowseFolder(
1212                        item_id=abs_collection.id_,
1213                        name=abs_collection.name,
1214                        provider=self.instance_id,
1215                        path=path,
1216                    )
1217                )
1218        return sorted(items, key=lambda x: x.name)
1219
1220    async def _browse_books(self, library_id: str) -> Sequence[MediaItemType]:
1221        if len(self.libraries.audiobooks[library_id].item_ids) == 0:
1222            self._log_no_helper_item_ids()
1223        items = []
1224        for book_id in self.libraries.audiobooks[library_id].item_ids:
1225            mass_item = await self.mass.music.get_library_item_by_prov_id(
1226                media_type=MediaType.AUDIOBOOK,
1227                item_id=book_id,
1228                provider_instance_id_or_domain=self.instance_id,
1229            )
1230            if mass_item is not None:
1231                items.append(mass_item)
1232        return sorted(items, key=lambda x: x.name)
1233
1234    async def _browse_author_books(
1235        self, current_path: str, author_id: str
1236    ) -> Sequence[MediaItemType | BrowseFolder]:
1237        items: list[MediaItemType | BrowseFolder] = []
1238
1239        abs_author = await self._client.get_author(
1240            author_id=author_id, include_items=True, include_series=True
1241        )
1242        if not isinstance(abs_author, AbsAuthorWithItemsAndSeries):
1243            raise TypeError("Unexpected type of author.")
1244
1245        book_ids = {x.id_ for x in abs_author.library_items}
1246        series_book_ids = set()
1247
1248        for series in abs_author.series:
1249            series_book_ids.update([x.id_ for x in series.items])
1250            path = f"{current_path}/{series.id_}"
1251            items.append(
1252                BrowseFolder(
1253                    item_id=series.id_,
1254                    # frontend does <name>: <translation>
1255                    name=series.name,
1256                    translation_key="series_singular",
1257                    provider=self.instance_id,
1258                    path=path,
1259                )
1260            )
1261        book_ids = book_ids.difference(series_book_ids)
1262        for book_id in book_ids:
1263            mass_item = await self.mass.music.get_library_item_by_prov_id(
1264                media_type=MediaType.AUDIOBOOK,
1265                item_id=book_id,
1266                provider_instance_id_or_domain=self.instance_id,
1267            )
1268            if mass_item is not None:
1269                items.append(mass_item)
1270
1271        return items
1272
1273    async def _browse_narrator_books(
1274        self, library_id: str, narrator_filter_str: str
1275    ) -> Sequence[MediaItemType]:
1276        items: list[MediaItemType] = []
1277        async for response in self._client.get_library_items(
1278            library_id=library_id, filter_str=f"narrators.{narrator_filter_str}"
1279        ):
1280            if not response.results:
1281                break
1282            for item in response.results:
1283                mass_item = await self.mass.music.get_library_item_by_prov_id(
1284                    media_type=MediaType.AUDIOBOOK,
1285                    item_id=item.id_,
1286                    provider_instance_id_or_domain=self.instance_id,
1287                )
1288                if mass_item is not None:
1289                    items.append(mass_item)
1290
1291        return sorted(items, key=lambda x: x.name)
1292
1293    async def _browse_series_books(self, series_id: str) -> Sequence[MediaItemType]:
1294        items = []
1295
1296        abs_series = await self._client.get_series(series_id=series_id, include_progress=True)
1297        if not isinstance(abs_series, AbsSeriesWithProgress):
1298            raise TypeError("Unexpected series type.")
1299
1300        for book_id in abs_series.progress.library_item_ids:
1301            # these are sorted in abs by sequence
1302            mass_item = await self.mass.music.get_library_item_by_prov_id(
1303                media_type=MediaType.AUDIOBOOK,
1304                item_id=book_id,
1305                provider_instance_id_or_domain=self.instance_id,
1306            )
1307            if mass_item is not None:
1308                items.append(mass_item)
1309
1310        return items
1311
1312    async def _browse_collection_books(self, collection_id: str) -> Sequence[MediaItemType]:
1313        items = []
1314        abs_collection = await self._client.get_collection(collection_id=collection_id)
1315        for book in abs_collection.books:
1316            mass_item = await self.mass.music.get_library_item_by_prov_id(
1317                media_type=MediaType.AUDIOBOOK,
1318                item_id=book.id_,
1319                provider_instance_id_or_domain=self.instance_id,
1320            )
1321            if mass_item is not None:
1322                items.append(mass_item)
1323        return items
1324
1325    async def _socket_abs_item_changed(
1326        self, items: LibraryItemExpanded | list[LibraryItemExpanded]
1327    ) -> None:
1328        """For added and updated."""
1329        abs_items = [items] if isinstance(items, LibraryItemExpanded) else items
1330        for abs_item in abs_items:
1331            if isinstance(abs_item, LibraryItemExpandedBook):
1332                # If the book has no audiofiles, we skip -> ebook only.
1333                if len(abs_item.media.tracks) == 0:
1334                    continue
1335                self.logger.debug(
1336                    'Updated book "%s" via socket.', abs_item.media.metadata.title or ""
1337                )
1338                await self.mass.music.audiobooks.add_item_to_library(
1339                    parse_audiobook(
1340                        abs_audiobook=abs_item,
1341                        instance_id=self.instance_id,
1342                        domain=self.domain,
1343                        token=self._client.token,
1344                        base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
1345                    ),
1346                    overwrite_existing=True,
1347                )
1348                lib = self.libraries.audiobooks.get(abs_item.library_id, None)
1349                if lib is not None:
1350                    lib.item_ids.add(abs_item.id_)
1351            elif isinstance(abs_item, LibraryItemExpandedPodcast):
1352                self.logger.debug(
1353                    'Updated podcast "%s" via socket.', abs_item.media.metadata.title or ""
1354                )
1355                mass_podcast = parse_podcast(
1356                    abs_podcast=abs_item,
1357                    instance_id=self.instance_id,
1358                    domain=self.domain,
1359                    token=self._client.token,
1360                    base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
1361                )
1362                if not (
1363                    bool(self.config.get_value(CONF_HIDE_EMPTY_PODCASTS))
1364                    and mass_podcast.total_episodes == 0
1365                ):
1366                    await self.mass.music.podcasts.add_item_to_library(
1367                        mass_podcast,
1368                        overwrite_existing=True,
1369                    )
1370                    lib = self.libraries.podcasts.get(abs_item.library_id, None)
1371                    if lib is not None:
1372                        lib.item_ids.add(abs_item.id_)
1373        await self._cache_set_helper_libraries()
1374
1375    async def _socket_abs_item_removed(self, item: LibraryItemRemoved) -> None:
1376        """Item removed."""
1377        media_type: MediaType | None = None
1378        for lib in self.libraries.audiobooks.values():
1379            if item.id_ in lib.item_ids:
1380                media_type = MediaType.AUDIOBOOK
1381                lib.item_ids.remove(item.id_)
1382                break
1383        for lib in self.libraries.podcasts.values():
1384            if item.id_ in lib.item_ids:
1385                media_type = MediaType.PODCAST
1386                lib.item_ids.remove(item.id_)
1387                break
1388
1389        if media_type is not None:
1390            mass_item = await self.mass.music.get_library_item_by_prov_id(
1391                media_type=media_type,
1392                item_id=item.id_,
1393                provider_instance_id_or_domain=self.instance_id,
1394            )
1395            if mass_item is not None:
1396                await self.mass.music.remove_item_from_library(
1397                    media_type=media_type, library_item_id=mass_item.item_id
1398                )
1399                self.logger.debug('Removed %s "%s" via socket.', media_type.value, mass_item.name)
1400
1401        await self._cache_set_helper_libraries()
1402
1403    async def _socket_abs_user_item_progress_updated(
1404        self, id_: str, progress: MediaProgress
1405    ) -> None:
1406        """To update continue listening.
1407
1408        ABS reports every 15s and immediately on play state change.
1409        This callback is called per item if a progress is changed:
1410            - a change in position
1411            - the item is finished
1412        But it is _not_called, if a progress is reset/ discarded.
1413        """
1414        # guard, see progress guard class docstrings for explanation
1415        if not self.progress_guard.guard_ok_abs(abs_progress=progress):
1416            return
1417
1418        known_ids = self._get_all_known_item_ids()
1419        if progress.library_item_id not in known_ids:
1420            return
1421
1422        self.logger.debug(f"Updated progress of item {progress.library_item_id} via socket.")
1423
1424        if progress.episode_id is None:
1425            await self._update_playlog_book(progress)
1426            return
1427        await self._update_playlog_episode(progress)
1428
1429    async def _socket_abs_refresh_token_expired(self) -> None:
1430        await self.reauthenticate()
1431
1432    async def reauthenticate(self) -> None:
1433        """Reauthorize the abs session config if refresh token expired."""
1434        # some safe guarding should that function be called simultaneously
1435        if self.reauthenticate_lock.locked() or time.time() - self.reauthenticate_last < 5:
1436            while True:
1437                if not self.reauthenticate_lock.locked():
1438                    return
1439                await asyncio.sleep(0.5)
1440        async with self.reauthenticate_lock:
1441            await self._client.session_config.authenticate(
1442                username=str(self.config.get_value(CONF_USERNAME)),
1443                password=str(self.config.get_value(CONF_PASSWORD)),
1444            )
1445            self.reauthenticate_last = time.time()
1446
1447    def _get_all_known_item_ids(self) -> set[str]:
1448        known_ids = set()
1449        for lib in self.libraries.podcasts.values():
1450            known_ids.update(lib.item_ids)
1451        for lib in self.libraries.audiobooks.values():
1452            known_ids.update(lib.item_ids)
1453
1454        return known_ids
1455
1456    async def _set_playlog_from_user(self, user: User) -> None:
1457        """Update on user callback.
1458
1459        User holds also all media progresses specific to that user.
1460
1461        The function 'guard_ok_abs' uses the timestamp of the last update in abs, thus after an
1462        initial progress update, an unchanged update will not trigger a (useless) playlog update.
1463
1464        We do not sync removed progresses for the sake of simplicity.
1465        """
1466        await self._set_playlog_from_user_sync(user.media_progress)
1467
1468    async def _set_playlog_from_user_sync(self, progresses: list[MediaProgress]) -> None:
1469        # for debugging
1470        __updated_items = 0
1471
1472        known_ids = self._get_all_known_item_ids()
1473        abs_ids_with_progress = set()
1474
1475        for progress in progresses:
1476            # save progress ids for later
1477            ma_item_id = (
1478                progress.library_item_id
1479                if progress.episode_id is None
1480                else f"{progress.library_item_id} {progress.episode_id}"
1481            )
1482            abs_ids_with_progress.add(ma_item_id)
1483
1484            # Guard. Also makes sure, that we don't write to db again if no state change happened.
1485            # This is achieved by adding a Helper Progress in the update playlog functions, which
1486            # then has the most recent timestamp. If a subsequent progress sent by abs has an older
1487            # timestamp, we do not update again.
1488            if not self.progress_guard.guard_ok_abs(progress):
1489                continue
1490            if progress.current_time is not None:
1491                if int(progress.current_time) != 0 and not progress.current_time >= 30:
1492                    # same as mass default, only > 30s
1493                    continue
1494            if progress.library_item_id not in known_ids:
1495                continue
1496            __updated_items += 1
1497            if progress.episode_id is None:
1498                await self._update_playlog_book(progress)
1499            else:
1500                await self._update_playlog_episode(progress)
1501        self.logger.debug(f"Updated {__updated_items} from full playlog.")
1502
1503        # Get MA's known progresses of ABS.
1504        # In ABS the user may discard a progress, which removes the progress completely.
1505        # There is no socket notification for this event.
1506        ma_playlog_state = await self.mass.music.get_playlog_provider_item_ids(
1507            provider_instance_id=self.instance_id
1508        )
1509        ma_ids_with_progress = {x for _, x in ma_playlog_state}
1510        discarded_progress_ids = ma_ids_with_progress.difference(abs_ids_with_progress)
1511        for discarded_progress_id in discarded_progress_ids:
1512            if len(discarded_progress_id.split(" ")) == 1:
1513                if discarded_item := await self.mass.music.get_library_item_by_prov_id(
1514                    media_type=MediaType.AUDIOBOOK,
1515                    item_id=discarded_progress_id,
1516                    provider_instance_id_or_domain=self.instance_id,
1517                ):
1518                    self.progress_guard.add_progress(discarded_progress_id)
1519                    await self.mass.music.mark_item_unplayed(discarded_item)
1520            else:
1521                with suppress(MediaNotFoundError):
1522                    discarded_item = await self.get_podcast_episode(
1523                        prov_episode_id=discarded_progress_id, add_progress=False
1524                    )
1525                    self.progress_guard.add_progress(*discarded_progress_id.split(" "))
1526                    await self.mass.music.mark_item_unplayed(discarded_item)
1527            self.logger.debug("Discarded item %s ", discarded_progress_id)
1528
1529    async def _update_playlog_book(self, progress: MediaProgress) -> None:
1530        # helper progress also ensures no useless progress updates,
1531        # see comment above
1532        self.progress_guard.add_progress(progress.library_item_id)
1533        if progress.current_time is None:
1534            return
1535        mass_audiobook = await self.mass.music.get_library_item_by_prov_id(
1536            media_type=MediaType.AUDIOBOOK,
1537            item_id=progress.library_item_id,
1538            provider_instance_id_or_domain=self.instance_id,
1539        )
1540        if mass_audiobook is None:
1541            return
1542        if int(progress.current_time) == 0:
1543            await self.mass.music.mark_item_unplayed(mass_audiobook)
1544        else:
1545            await self.mass.music.mark_item_played(
1546                mass_audiobook,
1547                fully_played=progress.is_finished,
1548                seconds_played=int(progress.current_time),
1549            )
1550
1551    async def _update_playlog_episode(self, progress: MediaProgress) -> None:
1552        # helper progress also ensures no useless progress updates,
1553        # see comment above
1554        self.progress_guard.add_progress(progress.library_item_id, progress.episode_id)
1555        if progress.current_time is None:
1556            return
1557        _episode_id = f"{progress.library_item_id} {progress.episode_id}"
1558        try:
1559            # need to obtain full podcast, and then search for episode
1560            mass_episode = await self.get_podcast_episode(_episode_id, add_progress=False)
1561        except MediaNotFoundError:
1562            return
1563        if int(progress.current_time) == 0:
1564            await self.mass.music.mark_item_unplayed(mass_episode)
1565        else:
1566            await self.mass.music.mark_item_played(
1567                mass_episode,
1568                fully_played=progress.is_finished,
1569                seconds_played=int(progress.current_time),
1570            )
1571
1572    async def _cache_set_helper_libraries(self) -> None:
1573        await self.mass.cache.set(
1574            key=CACHE_KEY_LIBRARIES,
1575            provider=self.instance_id,
1576            category=CACHE_CATEGORY_LIBRARIES,
1577            data=self.libraries.to_dict(),
1578        )
1579
1580    def _log_no_libraries(self) -> None:
1581        self.logger.error("There are no libraries visible to the Audiobookshelf provider.")
1582
1583    def _log_no_helper_item_ids(self) -> None:
1584        self.logger.warning(
1585            "Cached item ids are missing. "
1586            "Please trigger a full resync of the Audiobookshelf provider manually."
1587        )
1588