From 5a0b68bbffe2c36740a658c4af90f8f52ae2e40c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 18 Oct 2025 11:32:03 +0200 Subject: [PATCH] Add dynamic chapter url retrieval solution to ABS (#2513) * 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> --- .../providers/audiobookshelf/__init__.py | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index c67e326a..35f16ef4 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -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.""" -- 2.34.1