Audiobookshelf: Implement new JWT authorization (#2379)
authorFabian Munkes <105975993+fmunkes@users.noreply.github.com>
Sat, 13 Sep 2025 17:03:54 +0000 (19:03 +0200)
committerGitHub <noreply@github.com>
Sat, 13 Sep 2025 17:03:54 +0000 (19:03 +0200)
music_assistant/helpers/audio.py
music_assistant/helpers/ffmpeg.py
music_assistant/providers/audiobookshelf/__init__.py
music_assistant/providers/audiobookshelf/constants.py
music_assistant/providers/audiobookshelf/manifest.json
requirements_all.txt

index 15bb310425f6ef3ff8729226e3f4cc6a8372d4aa..e1b6038b4eebf41bbf93bf76fb7bfd540c76547c 100644 (file)
@@ -1288,6 +1288,7 @@ async def get_multi_file_stream(
     mass: MusicAssistant,  # noqa: ARG001
     streamdetails: StreamDetails,
     seek_position: int = 0,
+    raise_ffmpeg_exception: bool = False,
 ) -> AsyncGenerator[bytes, None]:
     """Return audio stream for a concatenation of multiple files.
 
@@ -1321,6 +1322,7 @@ async def get_multi_file_stream(
                 "-ss",
                 str(seek_position),
             ],
+            raise_ffmpeg_exception=raise_ffmpeg_exception,
         ):
             yield chunk
     finally:
index e936844668d1515664be725a8e0f49afd238a90a..d8c7e34da4b67d43c914944c401de4458ec6bb58 100644 (file)
@@ -200,6 +200,7 @@ async def get_ffmpeg_stream(
     extra_args: list[str] | None = None,
     chunk_size: int | None = None,
     extra_input_args: list[str] | None = None,
+    raise_ffmpeg_exception: bool = False,
 ) -> AsyncGenerator[bytes, None]:
     """
     Get the ffmpeg audio stream as async generator.
@@ -221,9 +222,12 @@ async def get_ffmpeg_stream(
         async for chunk in iterator:
             yield chunk
         if ffmpeg_proc.returncode not in (None, 0):
-            # dump the last 5 lines of the log in case of an unclean exit
             log_tail = "\n" + "\n".join(list(ffmpeg_proc.log_history)[-5:])
-            ffmpeg_proc.logger.error(log_tail)
+            if not raise_ffmpeg_exception:
+                # dump the last 5 lines of the log in case of an unclean exit
+                ffmpeg_proc.logger.error(log_tail)
+            else:
+                raise AudioError(log_tail)
 
 
 def get_ffmpeg_args(  # noqa: PLR0915
index 1a9f4eff38e8c96f4763406cc0a4a1fe23ee75b8..294c20a53be7ec827ad64bee2d866add6facd8be 100644 (file)
@@ -2,16 +2,20 @@
 
 from __future__ import annotations
 
+import functools
 import itertools
-from collections.abc import AsyncGenerator, Sequence
-from typing import TYPE_CHECKING
+import time
+from collections.abc import AsyncGenerator, Callable, Coroutine, Sequence
+from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
 
 import aioaudiobookshelf as aioabs
 from aioaudiobookshelf.client.items import LibraryItemExpandedBook as AbsLibraryItemExpandedBook
 from aioaudiobookshelf.client.items import (
     LibraryItemExpandedPodcast as AbsLibraryItemExpandedPodcast,
 )
+from aioaudiobookshelf.client.session_configuration import asyncio
 from aioaudiobookshelf.exceptions import LoginError as AbsLoginError
+from aioaudiobookshelf.exceptions import RefreshTokenExpiredError
 from aioaudiobookshelf.schema.author import AuthorExpanded
 from aioaudiobookshelf.schema.calls_authors import (
     AuthorWithItemsAndSeries as AbsAuthorWithItemsAndSeries,
@@ -45,7 +49,7 @@ from music_assistant_models.enums import (
     ProviderFeature,
     StreamType,
 )
-from music_assistant_models.errors import LoginFailed, MediaNotFoundError
+from music_assistant_models.errors import AudioError, LoginFailed, MediaNotFoundError
 from music_assistant_models.media_items import (
     Audiobook,
     AudioFormat,
@@ -71,9 +75,10 @@ from .constants import (
     ABS_SHELF_ID_ICONS,
     CACHE_CATEGORY_LIBRARIES,
     CACHE_KEY_LIBRARIES,
+    CONF_API_TOKEN,
     CONF_HIDE_EMPTY_PODCASTS,
+    CONF_OLD_TOKEN,
     CONF_PASSWORD,
-    CONF_TOKEN,
     CONF_URL,
     CONF_USERNAME,
     CONF_VERIFY_SSL,
@@ -122,8 +127,8 @@ async def get_config_entries(
             type=ConfigEntryType.LABEL,
             label="Please provide the address of your Audiobookshelf instance. To authenticate "
             "you have two options: "
-            "a) Provide username AND password. Leave token empty."
-            "b) Provide ONLY the token.",
+            "a) Provide username AND password. Leave the API key empty. "
+            "b) Provide ONLY an API key.",
         ),
         ConfigEntry(
             key=CONF_URL,
@@ -148,13 +153,20 @@ async def get_config_entries(
             description="The password to authenticate to the remote server.",
         ),
         ConfigEntry(
-            key=CONF_TOKEN,
+            key=CONF_API_TOKEN,
             type=ConfigEntryType.SECURE_STRING,
-            label="Token _instead_ of user/ password.",
+            label="API key _instead_ of user/ password. (ABS version >= 2.26)",
             required=False,
             description="Instead of using a username and password, "
-            "you may provide the user's token."
-            "\nThe token can be seen in Audiobookshelf as an admin user in Settings -> Users.",
+            "you may provide an API key (ABS version >= 2.26). "
+            "Please consult the docs.",
+        ),
+        ConfigEntry(
+            key=CONF_OLD_TOKEN,
+            type=ConfigEntryType.SECURE_STRING,
+            label="old token",
+            required=False,
+            hidden=True,
         ),
         ConfigEntry(
             key=CONF_VERIFY_SSL,
@@ -177,9 +189,31 @@ async def get_config_entries(
     )
 
 
+R = TypeVar("R")
+P = ParamSpec("P")
+
+
 class Audiobookshelf(MusicProvider):
     """Audiobookshelf MusicProvider."""
 
+    @staticmethod
+    def handle_refresh_token(
+        method: Callable[P, Coroutine[Any, Any, R]],
+    ) -> Callable[P, Coroutine[Any, Any, R]]:
+        """Decorate a method to handle an expired refresh token by relogin."""
+
+        @functools.wraps(method)
+        async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+            self = cast("Audiobookshelf", args[0])
+            try:
+                return await method(*args, **kwargs)
+            except RefreshTokenExpiredError:
+                self.logger.debug("Refresh token expired. Trying to renew.")
+                await self.reauthenticate()
+                return await method(*args, **kwargs)
+
+        return wrapper
+
     @property
     def supported_features(self) -> set[ProviderFeature]:
         """Features supported by this Provider."""
@@ -195,7 +229,8 @@ class Audiobookshelf(MusicProvider):
         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))
-        token = self.config.get_value(CONF_TOKEN)
+        token_old = self.config.get_value(CONF_OLD_TOKEN)
+        token_api = self.config.get_value(CONF_API_TOKEN)
         verify_ssl = bool(self.config.get_value(CONF_VERIFY_SSL))
         session_config = aioabs.SessionConfiguration(
             session=self.mass.http_session,
@@ -205,8 +240,9 @@ class Audiobookshelf(MusicProvider):
             pagination_items_per_page=30,  # audible provider goes with 50 for pagination
         )
         try:
-            if token is not None:
-                session_config.token = str(token)
+            if token_api is not None or token_old is not None:
+                _token = token_api if token_api is not None else token_old
+                session_config.token = str(_token)
                 (
                     self._client,
                     self._client_socket,
@@ -219,6 +255,32 @@ class Audiobookshelf(MusicProvider):
         except AbsLoginError as exc:
             raise LoginFailed(f"Login to abs instance at {base_url} failed.") from exc
 
+        if token_old is not None and token_api is None:
+            # Log Message that the old token won't work
+            _version = self._client.server_settings.version.split(".")
+            if len(_version) >= 2:
+                try:
+                    major, minor = int(_version[0]), int(_version[1])
+                except ValueError:
+                    major = minor = 0
+                if major >= 2 and minor >= 26:
+                    self.logger.warning(
+                        """
+
+######## Audiobookshelf API key change #############################################################
+
+Audiobookshelf introduced a new API key system in version 2.26 (JWT).
+You are still using a token configured with a previous version of Audiobookshelf,
+but you are running version %s. This will stop working in a future Audiobookshelf release.
+Please create a non-expiring API Key instead, and update your configuration accordingly.
+Refer to the documentation of Audiobookshelf, https://www.audiobookshelf.org/guides/api-keys/
+and of Music Assistant https://www.music-assistant.io/music-providers/audiobookshelf/
+for more details.
+
+""",
+                        self._client.server_settings.version,
+                    )
+
         self.cache_base_key = self.instance_id
 
         cached_libraries = await self.mass.cache.get(
@@ -256,6 +318,10 @@ class Audiobookshelf(MusicProvider):
             on_user_item_progress_updated=self._socket_abs_user_item_progress_updated,
         )
 
+        self._client_socket.set_refresh_token_expired_callback(
+            on_refresh_token_expired=self._socket_abs_refresh_token_expired
+        )
+
         # progress guard
         self.progress_guard = ProgressGuard()
 
@@ -263,6 +329,11 @@ class Audiobookshelf(MusicProvider):
         user = await self._client.get_my_user()
         await self._set_playlog_from_user(user)
 
+        # safe guard reauthentication
+        self.reauthenticate_lock = asyncio.Lock()
+        self.reauthenticate_last = 0.0
+
+    @handle_refresh_token
     async def unload(self, is_removed: bool = False) -> None:
         """
         Handle unload/close of the provider.
@@ -279,6 +350,7 @@ class Audiobookshelf(MusicProvider):
         # For streaming providers return True here but for local file based providers return False.
         return False
 
+    @handle_refresh_token
     async def sync_library(self, media_type: MediaType) -> None:
         """Obtain audiobook library ids and podcast library ids."""
         libraries = await self._client.get_all_libraries()
@@ -328,6 +400,7 @@ class Audiobookshelf(MusicProvider):
                         continue
                     yield mass_podcast
 
+    @handle_refresh_token
     async def _get_abs_expanded_podcast(
         self, prov_podcast_id: str
     ) -> AbsLibraryItemExpandedPodcast:
@@ -338,6 +411,7 @@ class Audiobookshelf(MusicProvider):
 
         return abs_podcast
 
+    @handle_refresh_token
     async def get_podcast(self, prov_podcast_id: str) -> Podcast:
         """Get single podcast."""
         abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
@@ -384,6 +458,7 @@ class Audiobookshelf(MusicProvider):
             yield mass_episode
             episode_cnt += 1
 
+    @handle_refresh_token
     async def get_podcast_episode(
         self, prov_episode_id: str, add_progress: bool = True
     ) -> PodcastEpisode:
@@ -441,6 +516,7 @@ class Audiobookshelf(MusicProvider):
                     )
                     yield mass_audiobook
 
+    @handle_refresh_token
     async def _get_abs_expanded_audiobook(
         self, prov_audiobook_id: str
     ) -> AbsLibraryItemExpandedBook:
@@ -451,6 +527,7 @@ class Audiobookshelf(MusicProvider):
 
         return abs_audiobook
 
+    @handle_refresh_token
     async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
         """Get a single audiobook.
 
@@ -480,10 +557,12 @@ class Audiobookshelf(MusicProvider):
     async def _get_stream_details_audiobook(
         self, abs_audiobook: AbsLibraryItemExpandedBook
     ) -> StreamDetails:
-        """Streamdetails audiobook."""
+        """Streamdetails audiobook.
+
+        We always use a custom stream type, also for single file, such
+        that we can handle an ffmpeg error and refresh our tokens.
+        """
         tracks = abs_audiobook.media.tracks
-        token = self._client.token
-        base_url = str(self.config.get_value(CONF_URL))
         if len(tracks) == 0:
             raise MediaNotFoundError("Stream not found")
 
@@ -491,36 +570,14 @@ class Audiobookshelf(MusicProvider):
         if abs_audiobook.media.tracks[0].metadata is not None:
             content_type = ContentType.try_parse(abs_audiobook.media.tracks[0].metadata.ext)
 
-        if len(tracks) > 1:
-            self.logger.debug("Using playback for multiple file audiobook.")
-
-            return StreamDetails(
-                provider=self.instance_id,
-                item_id=abs_audiobook.id_,
-                audio_format=AudioFormat(content_type=content_type),
-                media_type=MediaType.AUDIOBOOK,
-                stream_type=StreamType.CUSTOM,
-                duration=int(abs_audiobook.media.duration),
-                data=tracks,
-                can_seek=True,
-                allow_seek=True,
-            )
-
-        self.logger.debug(
-            f'Using direct playback for audiobook "{abs_audiobook.media.metadata.title}".'
-        )
-        media_url = abs_audiobook.media.tracks[0].content_url
-        stream_url = f"{base_url}{media_url}?token={token}"
-
         return StreamDetails(
             provider=self.lookup_key,
             item_id=abs_audiobook.id_,
-            audio_format=AudioFormat(
-                content_type=content_type,
-            ),
+            audio_format=AudioFormat(content_type=content_type),
             media_type=MediaType.AUDIOBOOK,
-            stream_type=StreamType.HTTP,
-            path=stream_url,
+            stream_type=StreamType.CUSTOM,
+            duration=int(abs_audiobook.media.duration),
+            data=tracks,
             can_seek=True,
             allow_seek=True,
         )
@@ -569,38 +626,66 @@ class Audiobookshelf(MusicProvider):
         streamdetails: The stream to be used
         seek_position: The seeking position in seconds
         """
-        tracks, position = self._get_track_from_position(streamdetails.data, seek_position)
-        if not tracks:
-            raise MediaNotFoundError(f"Track not found at seek position {seek_position}.")
 
-        self.logger.debug(
-            f"Skipped {len(streamdetails.data) - len(tracks)} tracks while seeking to position {seek_position}."  # noqa: E501
-        )
-        base_url = str(self.config.get_value(CONF_URL))
-        track_urls = []
-        for track in tracks:
-            stream_url = f"{base_url}{track.content_url}?token={self._client.token}"
-            track_urls.append(stream_url)
-
-        async for chunk in get_multi_file_stream(
-            mass=self.mass,
-            streamdetails=StreamDetails(
-                provider=self.instance_id,
-                item_id=streamdetails.item_id,
-                audio_format=streamdetails.audio_format,
-                media_type=MediaType.AUDIOBOOK,
-                stream_type=StreamType.MULTI_FILE,
-                duration=streamdetails.duration,
-                data=track_urls,
-                can_seek=True,
-                allow_seek=True,
-            ),
-            seek_position=position,
-        ):
-            yield chunk
+        async def _get_audio_stream() -> AsyncGenerator[bytes, None]:
+            tracks, position = self._get_track_from_position(streamdetails.data, seek_position)
+            if not tracks:
+                raise MediaNotFoundError(f"Track not found at seek position {seek_position}.")
+
+            self.logger.debug(
+                f"Skipped {len(streamdetails.data) - len(tracks)} tracks"
+                " while seeking to position {seek_position}."
+            )
+            base_url = str(self.config.get_value(CONF_URL))
+            track_urls = []
+            for track in tracks:
+                stream_url = f"{base_url}{track.content_url}?token={self._client.token}"
+                track_urls.append(stream_url)
+
+            async for chunk in get_multi_file_stream(
+                mass=self.mass,
+                streamdetails=StreamDetails(
+                    provider=self.lookup_key,
+                    item_id=streamdetails.item_id,
+                    audio_format=streamdetails.audio_format,
+                    media_type=MediaType.AUDIOBOOK,
+                    stream_type=StreamType.MULTI_FILE,
+                    duration=streamdetails.duration,
+                    data=track_urls,
+                    can_seek=True,
+                    allow_seek=True,
+                ),
+                seek_position=position,
+                raise_ffmpeg_exception=True,
+            ):
+                yield chunk
+
+        # Should our token expire, we try to refresh them and continue streaming once.
+        _refreshed = False
+        while True:
+            try:
+                async for chunk in _get_audio_stream():
+                    _refreshed = False
+                    yield chunk
+                break
+            except AudioError as err:
+                if not _refreshed:
+                    self.logger.debug("FFmpeg raised an error. Trying to refresh token.")
+                    try:
+                        await self._client.session_config.refresh()
+                    except RefreshTokenExpiredError:
+                        await self.reauthenticate()
+                    _refreshed = True
+                else:
+                    self.logger.error(err)
+                    break
 
     async def _get_stream_details_episode(self, podcast_id: str) -> StreamDetails:
-        """Streamdetails of a podcast episode."""
+        """Streamdetails of a podcast episode.
+
+        There are no multi-file podcasts in abs, but we use a custom
+        stream to handle possible ffmpeg errors.
+        """
         abs_podcast_id, abs_episode_id = podcast_id.split(" ")
         abs_episode = None
 
@@ -611,10 +696,6 @@ class Audiobookshelf(MusicProvider):
         if abs_episode is None:
             raise MediaNotFoundError("Stream not found")
         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)
@@ -625,12 +706,13 @@ class Audiobookshelf(MusicProvider):
                 content_type=content_type,
             ),
             media_type=MediaType.PODCAST_EPISODE,
-            stream_type=StreamType.HTTP,
-            path=full_url,
+            stream_type=StreamType.CUSTOM,
             can_seek=True,
             allow_seek=True,
+            data=[abs_episode.audio_track],
         )
 
+    @handle_refresh_token
     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
@@ -649,6 +731,7 @@ class Audiobookshelf(MusicProvider):
 
         return False, 0
 
+    @handle_refresh_token
     async def recommendations(self) -> list[RecommendationFolder]:
         """Get recommendations."""
         # We have to avoid "flooding" the home page, which becomes especially troublesome if users
@@ -862,6 +945,7 @@ class Audiobookshelf(MusicProvider):
             items_collected.append(items)
             items_by_shelf_id[shelf.id_] = items_collected
 
+    @handle_refresh_token
     async def on_played(
         self,
         media_type: MediaType,
@@ -941,6 +1025,7 @@ class Audiobookshelf(MusicProvider):
                 is_finished=fully_played,
             )
 
+    @handle_refresh_token
     async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
         """Browse for audiobookshelf.
 
@@ -1354,6 +1439,24 @@ class Audiobookshelf(MusicProvider):
             return
         await self._update_playlog_episode(progress)
 
+    async def _socket_abs_refresh_token_expired(self) -> None:
+        await self.reauthenticate()
+
+    async def reauthenticate(self) -> None:
+        """Reauthorize the abs session config if refresh token expired."""
+        # some safe guarding should that function be called simultaneously
+        if self.reauthenticate_lock.locked() or time.time() - self.reauthenticate_last < 5:
+            while True:
+                if not self.reauthenticate_lock.locked():
+                    return
+                await asyncio.sleep(0.5)
+        async with self.reauthenticate_lock:
+            await self._client.session_config.authenticate(
+                username=str(self.config.get_value(CONF_USERNAME)),
+                password=str(self.config.get_value(CONF_PASSWORD)),
+            )
+            self.reauthenticate_last = time.time()
+
     def _get_all_known_item_ids(self) -> set[str]:
         known_ids = set()
         for lib in self.libraries.podcasts.values():
index fd659de811527c1261b2112dd79350f6253cca15..8ef7e37f35d87ee2e3911279b38fe2cf0bfc7784 100644 (file)
@@ -8,7 +8,8 @@ from aioaudiobookshelf.schema.shelf import ShelfId as AbsShelfId
 CONF_URL = "url"
 CONF_USERNAME = "username"
 CONF_PASSWORD = "password"
-CONF_TOKEN = "token"
+CONF_OLD_TOKEN = "token"
+CONF_API_TOKEN = "api_token"  # with jwt api token (>= v2.26)
 CONF_VERIFY_SSL = "verify_ssl"
 # optionally hide podcasts with no episodes
 CONF_HIDE_EMPTY_PODCASTS = "hide_empty_podcasts"
index 0dcfe66c388ada89b0e44e094cab4734da3e41e0..98ebf9ab02c97d82d01cd99df5e0692aca8d215e 100644 (file)
@@ -5,7 +5,7 @@
   "name": "Audiobookshelf",
   "description": "Audiobookshelf (audiobookshelf.org) as audiobook and podcast provider",
   "codeowners": ["@fmunkes"],
-  "requirements": ["aioaudiobookshelf==0.1.7"],
+  "requirements": ["aioaudiobookshelf==0.1.8"],
   "documentation": "https://music-assistant.io/music-providers/audiobookshelf",
   "multi_instance": true
 }
index 7417882ab325812b7b8be658e609655d2cc62714..0bbef93f80e3ad1e09aa46d762174336a0f8ed3b 100644 (file)
@@ -1,7 +1,7 @@
 # WARNING: this file is autogenerated!
 
 Brotli>=1.0.9
-aioaudiobookshelf==0.1.7
+aioaudiobookshelf==0.1.8
 aiodns>=3.2.0
 aiofiles==24.1.0
 aiohttp==3.12.15