Add dynamic chapter url retrieval solution to ABS (#2513)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 18 Oct 2025 09:32:03 +0000 (11:32 +0200)
committerGitHub <noreply@github.com>
Sat, 18 Oct 2025 09:32:03 +0000 (11:32 +0200)
* Add dynamic chapter url retrieval solution to ABS

* fix: missing slash in dynamic route

* rename chapter -> part

* use dynamic url for all parts

---------

Co-authored-by: Fabian Munkes <105975993+fmunkes@users.noreply.github.com>
music_assistant/providers/audiobookshelf/__init__.py

index c67e326a5fdc7f6c9f3866a9c4ae3f58f0b433d5..35f16ef4013dd7818d1d8abd5d2b043fc9aefe25 100644 (file)
@@ -39,6 +39,7 @@ from aioaudiobookshelf.schema.shelf import (
 )
 from aioaudiobookshelf.schema.shelf import ShelfId as AbsShelfId
 from aioaudiobookshelf.schema.shelf import ShelfType as AbsShelfType
+from aiohttp import web
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
 from music_assistant_models.enums import (
     ConfigEntryType,
@@ -200,6 +201,8 @@ P = ParamSpec("P")
 class Audiobookshelf(MusicProvider):
     """Audiobookshelf MusicProvider."""
 
+    _on_unload_callbacks: list[Callable[[], None]]
+
     @staticmethod
     def handle_refresh_token(
         method: Callable[P, Coroutine[Any, Any, R]],
@@ -220,6 +223,7 @@ class Audiobookshelf(MusicProvider):
 
     async def handle_async_init(self) -> None:
         """Pass config values to client and initialize."""
+        self._on_unload_callbacks: list[Callable[[], None]] = []
         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))
@@ -325,6 +329,13 @@ for more details.
         self.reauthenticate_lock = asyncio.Lock()
         self.reauthenticate_last = 0.0
 
+        # register dynamic stream route for audiobook parts
+        self._on_unload_callbacks.append(
+            self.mass.streams.register_dynamic_route(
+                f"/{self.instance_id}_part_stream", self._handle_audiobook_part_request
+            )
+        )
+
     @handle_refresh_token
     async def unload(self, is_removed: bool = False) -> None:
         """
@@ -335,6 +346,8 @@ for more details.
         """
         await self._client.logout()
         await self._client_socket.logout()
+        for callback in self._on_unload_callbacks:
+            callback()
 
     @property
     def is_streaming_provider(self) -> bool:
@@ -565,9 +578,15 @@ for more details.
             content_type = ContentType.try_parse(abs_audiobook.media.tracks[0].metadata.ext)
 
         file_parts: list[MultiPartPath] = []
-        base_url = str(self.config.get_value(CONF_URL))
-        for track in tracks:
-            stream_url = f"{base_url}{track.content_url}?token={self._client.token}"
+        for idx, track in enumerate(tracks):
+            # to ensure token is always valid, we create a dynamic url
+            # this ensures that we always get a fresh token on each part
+            # without having to deal with a custom stream etc.
+            # we also use this for the first part, otherwise we can't seek
+            stream_url = (
+                f"{self.mass.streams.base_url}/{self.instance_id}_part_stream?"
+                f"audiobook_id={abs_audiobook.id_}&part_id={idx}"
+            )
             file_parts.append(MultiPartPath(path=stream_url, duration=track.duration))
 
         return StreamDetails(
@@ -616,6 +635,30 @@ for more details.
             path=stream_url,
         )
 
+    async def _handle_audiobook_part_request(self, request: web.Request) -> web.Response:
+        """
+        Handle dynamic audiobook part stream request.
+
+        We redirect to the actual stream url with token.
+        This is done because the token might expire, so we need to
+        generate a fresh url on each part.
+        """
+        if not (audiobook_id := request.query.get("audiobook_id")):
+            return web.Response(status=400, text="Missing audiobook_id")
+        if not (part_id := request.query.get("part_id")):
+            return web.Response(status=400, text="Missing part_id")
+        abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=audiobook_id)
+        part_id = int(part_id)  # type: ignore[assignment]
+        try:
+            part_track = abs_audiobook.media.tracks[part_id]
+        except IndexError:
+            return web.Response(status=404, text="Part not found")
+
+        base_url = str(self.config.get_value(CONF_URL))
+        stream_url = f"{base_url}{part_track.content_url}?token={self._client.token}"
+        # redirect to the actual stream url
+        raise web.HTTPFound(location=stream_url)
+
     @handle_refresh_token
     async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:
         """Return finished:bool, position_ms: int."""