audiobookshelf: Implement more efficient multi-file seeking (#2342)
authorNikos Tsipinakis <nikos@tsipinakis.com>
Sun, 7 Sep 2025 11:18:31 +0000 (13:18 +0200)
committerGitHub <noreply@github.com>
Sun, 7 Sep 2025 11:18:31 +0000 (13:18 +0200)
The previous seeking implementation relied on downloading all audiobook
files and concatenating them together (and then doing it again every
time the user seeked). With a large book, this very quickly became slow
and a resource hog.

A more efficient way is to use the audio track duration information to
determine which track to start the playback at.

music_assistant/helpers/audio.py
music_assistant/providers/audiobookshelf/__init__.py

index 1f3253c1d2cbae512ba7eded33c969701d5db296..15bb310425f6ef3ff8729226e3f4cc6a8372d4aa 100644 (file)
@@ -1287,8 +1287,13 @@ async def get_file_stream(
 async def get_multi_file_stream(
     mass: MusicAssistant,  # noqa: ARG001
     streamdetails: StreamDetails,
+    seek_position: int = 0,
 ) -> AsyncGenerator[bytes, None]:
-    """Return audio stream for a concatenation of multiple files."""
+    """Return audio stream for a concatenation of multiple files.
+
+    Arguments:
+    seek_position: The position to seek to in seconds
+    """
     files_list: list[str] = streamdetails.data
     # concat input files
     temp_file = f"/tmp/{shortuuid.random(20)}.txt"  # noqa: S108
@@ -1306,7 +1311,16 @@ async def get_multi_file_stream(
                 bit_depth=streamdetails.audio_format.bit_depth,
                 channels=streamdetails.audio_format.channels,
             ),
-            extra_input_args=["-safe", "0", "-f", "concat", "-i", temp_file],
+            extra_input_args=[
+                "-safe",
+                "0",
+                "-f",
+                "concat",
+                "-i",
+                temp_file,
+                "-ss",
+                str(seek_position),
+            ],
         ):
             yield chunk
     finally:
index c79766d6a59d45f72adbf228ebf0f667e5e44fe2..1a9f4eff38e8c96f4763406cc0a4a1fe23ee75b8 100644 (file)
@@ -58,6 +58,7 @@ from music_assistant_models.media_items import (
 from music_assistant_models.media_items.media_item import RecommendationFolder
 from music_assistant_models.streamdetails import StreamDetails
 
+from music_assistant.helpers.audio import get_multi_file_stream
 from music_assistant.models.music_provider import MusicProvider
 from music_assistant.providers.audiobookshelf.parsers import (
     parse_audiobook,
@@ -83,6 +84,7 @@ from .constants import (
 from .helpers import LibrariesHelper, LibraryHelper, ProgressGuard
 
 if TYPE_CHECKING:
+    from aioaudiobookshelf.schema.audio import AudioTrack as AbsAudioTrack
     from aioaudiobookshelf.schema.events_socket import LibraryItemRemoved
     from aioaudiobookshelf.schema.media_progress import MediaProgress
     from aioaudiobookshelf.schema.user import User
@@ -491,19 +493,16 @@ class Audiobookshelf(MusicProvider):
 
         if len(tracks) > 1:
             self.logger.debug("Using playback for multiple file audiobook.")
-            multiple_files: list[str] = []
-            for track in tracks:
-                stream_url = f"{base_url}{track.content_url}?token={token}"
-                multiple_files.append(stream_url)
 
             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.MULTI_FILE,
+                stream_type=StreamType.CUSTOM,
                 duration=int(abs_audiobook.media.duration),
-                data=multiple_files,
+                data=tracks,
+                can_seek=True,
                 allow_seek=True,
             )
 
@@ -526,6 +525,80 @@ class Audiobookshelf(MusicProvider):
             allow_seek=True,
         )
 
+    def _get_track_from_position(
+        self, tracks: list[AbsAudioTrack], seek_position: int
+    ) -> tuple[list[AbsAudioTrack] | None, int]:
+        """Get the remaining tracks list from a timestamp.
+
+        Arguments:
+        tracks: The list of Audiobookshelf tracks
+        seek_position: The seeking position in seconds of the tracklist
+
+        Returns:
+            In a tuple, A list of audiobookshelf tracks, starting with the one at the requested seek
+        position and the position in seconds to seek to in the first track.
+            A tuple of None and 0 if the track wasn't found
+        """
+        for i, track in enumerate(tracks):
+            offset = int(track.start_offset)
+            duration = int(track.duration)
+            if offset + duration < seek_position:
+                continue
+
+            position = int(seek_position) - offset
+
+            # Seeking in some tracks is inaccurate, making the seek to a chapter land on the end of
+            # the previous track. If we're within 2 second of the end, skip the current track
+            if position + 2 >= duration:
+                self.logger.debug(
+                    f"Skipping {track.title} due to seek position being at the end: {position}"
+                )
+                continue
+
+            position = max(position, 0)
+
+            return tracks[i:], position
+        return None, 0
+
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """Retrieve the audio track at the requested position.
+
+        Arguments:
+        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_stream_details_episode(self, podcast_id: str) -> StreamDetails:
         """Streamdetails of a podcast episode."""
         abs_podcast_id, abs_episode_id = podcast_id.split(" ")