)
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,
class Audiobookshelf(MusicProvider):
"""Audiobookshelf MusicProvider."""
+ _on_unload_callbacks: list[Callable[[], None]]
+
@staticmethod
def handle_refresh_token(
method: Callable[P, Coroutine[Any, Any, R]],
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))
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:
"""
"""
await self._client.logout()
await self._client_socket.logout()
+ for callback in self._on_unload_callbacks:
+ callback()
@property
def is_streaming_provider(self) -> bool:
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(
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."""