Rework audio cache/buffering (#2483)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 5 Oct 2025 15:05:26 +0000 (17:05 +0200)
committerGitHub <noreply@github.com>
Sun, 5 Oct 2025 15:05:26 +0000 (17:05 +0200)
12 files changed:
music_assistant/controllers/player_queues.py
music_assistant/controllers/streams.py
music_assistant/helpers/audio.py
music_assistant/helpers/ffmpeg.py
music_assistant/helpers/playlists.py
music_assistant/helpers/smart_fades.py
music_assistant/providers/audiobookshelf/__init__.py
music_assistant/providers/builtin_player/player.py
music_assistant/providers/filesystem_local/__init__.py
music_assistant/providers/opensubsonic/sonic_provider.py
pyproject.toml
requirements_all.txt

index 4ccc2014a875d5fcd2c4ad3e3de0e09464e7d5cb..4900aa4be2d8a32c2642dbbacf2643c7a4f7da28 100644 (file)
@@ -59,7 +59,6 @@ from music_assistant_models.queue_item import QueueItem
 from music_assistant.constants import (
     ATTR_ANNOUNCEMENT_IN_PROGRESS,
     CONF_FLOW_MODE,
-    CONF_SMART_FADES_MODE,
     MASS_LOGO_ONLINE,
     VERBOSE_LOG_LEVEL,
 )
@@ -69,7 +68,6 @@ from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER
 from music_assistant.helpers.util import get_changed_keys, percentage
 from music_assistant.models.core_controller import CoreController
 from music_assistant.models.player import Player, PlayerMedia
-from music_assistant.models.smart_fades import SmartFadesMode
 
 if TYPE_CHECKING:
     from collections.abc import Iterator
@@ -853,9 +851,12 @@ class PlayerQueuesController(CoreController):
                 # all attempts to find a playable item failed
                 raise MediaNotFoundError("No playable item found to start playback")
 
+            flow_mode = queue.flow_mode
+            if queue_item.media_type in (MediaType.RADIO, MediaType.PLUGIN_SOURCE):
+                flow_mode = False
             await self.mass.players.play_media(
                 player_id=queue_id,
-                media=await self.player_media_from_queue_item(queue_item, queue.flow_mode),
+                media=await self.player_media_from_queue_item(queue_item, flow_mode),
             )
             await asyncio.sleep(2)
             self._transitioning_players.discard(queue_id)
@@ -1157,14 +1158,6 @@ class PlayerQueuesController(CoreController):
             fade_in=fade_in,
             prefer_album_loudness=playing_album_tracks,
         )
-        # allow stripping silence from the begin/end of the track if crossfade is enabled
-        # this will allow for (much) smoother crossfades
-        if (
-            await self.mass.config.get_player_config_value(queue_id, CONF_SMART_FADES_MODE)
-            != SmartFadesMode.DISABLED
-        ):
-            queue_item.streamdetails.strip_silence_end = True
-            queue_item.streamdetails.strip_silence_begin = not is_start
 
     def track_loaded_in_buffer(self, queue_id: str, item_id: str) -> None:
         """Call when a player has (started) loading a track in the buffer."""
@@ -1570,7 +1563,6 @@ class PlayerQueuesController(CoreController):
         Preload the streamdetails for the next item in the queue/buffer.
 
         This basically ensures the item is playable and fetches the stream details.
-        If caching is enabled, this will also start filling the stream cache.
         If an error occurs, the item will be skipped and the next item will be loaded.
         """
         queue = self._queues[queue_id]
index 273fc3f2e645b4c713eacfb788e66e11aaad0a35..f44135154f5a554ebe1577159012f66e1d374640 100644 (file)
@@ -221,28 +221,6 @@ class StreamsController(CoreController):
                 category="advanced",
                 required=False,
             ),
-            # ConfigEntry(
-            #     key=CONF_ALLOW_AUDIO_CACHE,
-            #     type=ConfigEntryType.STRING,
-            #     default_value=self.allow_cache_default,
-            #     options=[
-            #         ConfigValueOption("Always", "always"),
-            #         ConfigValueOption("Disabled", "disabled"),
-            #         ConfigValueOption("Auto", "auto"),
-            #     ],
-            #     label="Allow caching of remote/cloudbased audio streams",
-            #     description="To ensure smooth(er) playback as well as fast seeking, "
-            #     "Music Assistant can cache audio streams on disk. \n"
-            #     "On systems with limited diskspace, this can be disabled, "
-            #     "but may result in less smooth playback or slower seeking.\n\n"
-            #     "**Always:** Enforce caching of audio streams at all times "
-            #     "(as long as there is enough free space).\n"
-            #     "**Disabled:** Never cache audio streams.\n"
-            #     "**Auto:** Let Music Assistant decide if caching "
-            #     "should be used on a per-item base.",
-            #     category="advanced",
-            #     required=True,
-            # ),
         )
 
     async def setup(self, config: CoreConfig) -> None:
@@ -413,20 +391,16 @@ class StreamsController(CoreController):
         if request.method != "GET":
             return resp
 
-        # work out pcm format based on output format
-        pcm_format = AudioFormat(
-            content_type=DEFAULT_PCM_FORMAT.content_type,
-            sample_rate=output_format.sample_rate,
-            # always use f32 internally for extra headroom for filters etc
-            bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
-            channels=2,
-        )
-        smart_fades_mode = await self.mass.config.get_player_config_value(
-            queue.queue_id, CONF_SMART_FADES_MODE
-        )
-        standard_crossfade_duration = self.mass.config.get_raw_player_config_value(
-            queue.queue_id, CONF_CROSSFADE_DURATION, 10
-        )
+        if queue_item.media_type != MediaType.TRACK:
+            # no crossfade on non-tracks
+            smart_fades_mode = SmartFadesMode.DISABLED
+        else:
+            smart_fades_mode = await self.mass.config.get_player_config_value(
+                queue.queue_id, CONF_SMART_FADES_MODE
+            )
+            standard_crossfade_duration = self.mass.config.get_raw_player_config_value(
+                queue.queue_id, CONF_CROSSFADE_DURATION, 10
+            )
         if (
             smart_fades_mode != SmartFadesMode.DISABLED
             and PlayerFeature.GAPLESS_PLAYBACK not in queue_player.supported_features
@@ -442,28 +416,43 @@ class StreamsController(CoreController):
             # crossfade is enabled, use special crossfaded single item stream
             # where the crossfade of the next track is present in the stream of
             # a single track. This only works if the player supports gapless playback.
-            audio_input = self.get_queue_item_stream_with_smartfade(
-                queue_item=queue_item,
-                pcm_format=pcm_format,
-                session_id=session_id,
-                smart_fades_mode=smart_fades_mode,
-                standard_crossfade_duration=standard_crossfade_duration,
+            # work out pcm format based on output format
+            pcm_format = AudioFormat(
+                content_type=DEFAULT_PCM_FORMAT.content_type,
+                sample_rate=output_format.sample_rate,
+                # always use f32 internally for extra headroom for filters etc
+                bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+                channels=2,
+            )
+            audio_input = get_ffmpeg_stream(
+                audio_input=self.get_queue_item_stream_with_smartfade(
+                    queue_item=queue_item,
+                    pcm_format=pcm_format,
+                    session_id=session_id,
+                    smart_fades_mode=smart_fades_mode,
+                    standard_crossfade_duration=standard_crossfade_duration,
+                ),
+                input_format=pcm_format,
+                output_format=output_format,
+                filter_params=get_player_filter_params(
+                    self.mass, queue_player.player_id, pcm_format, output_format
+                ),
             )
         else:
+            # no crossfade, just a regular single item stream
+            # no need to convert to pcm first, request output format directly
             audio_input = self.get_queue_item_stream(
                 queue_item=queue_item,
-                pcm_format=pcm_format,
+                output_format=output_format,
+                filter_params=get_player_filter_params(
+                    self.mass,
+                    queue_player.player_id,
+                    queue_item.streamdetails.audio_format,
+                    output_format,
+                ),
             )
 
-        async for chunk in get_ffmpeg_stream(
-            audio_input=audio_input,
-            input_format=pcm_format,
-            output_format=output_format,
-            filter_params=get_player_filter_params(
-                self.mass, queue_player.player_id, pcm_format, output_format
-            ),
-            chunk_size=get_chunksize(output_format),
-        ):
+        async for chunk in audio_input:
             try:
                 await resp.write(chunk)
             except (BrokenPipeError, ConnectionResetError, ConnectionError):
@@ -788,12 +777,19 @@ class StreamsController(CoreController):
         pcm_sample_size = int(
             pcm_format.sample_rate * (pcm_format.bit_depth / 8) * pcm_format.channels
         )
-        smart_fades_mode = await self.mass.config.get_player_config_value(
-            queue.queue_id, CONF_SMART_FADES_MODE
-        )
-        standard_crossfade_duration = self.mass.config.get_raw_player_config_value(
-            queue.queue_id, CONF_CROSSFADE_DURATION, 10
-        )
+        if start_queue_item.media_type != MediaType.TRACK:
+            # no crossfade on non-tracks
+            # NOTE that we shouldn't be using flow mode for non-tracks at all,
+            # but just to be sure, we specifically disable crossfade here
+            smart_fades_mode = SmartFadesMode.DISABLED
+            standard_crossfade_duration = 0
+        else:
+            smart_fades_mode = await self.mass.config.get_player_config_value(
+                queue.queue_id, CONF_SMART_FADES_MODE
+            )
+            standard_crossfade_duration = self.mass.config.get_raw_player_config_value(
+                queue.queue_id, CONF_CROSSFADE_DURATION, 10
+            )
         self.logger.info(
             "Start Queue Flow stream for Queue %s - crossfade: %s %s",
             queue.display_name,
@@ -840,7 +836,7 @@ class StreamsController(CoreController):
             # handle incoming audio chunks
             async for chunk in self.get_queue_item_stream(
                 queue_track,
-                pcm_format=pcm_format,
+                output_format=pcm_format,
             ):
                 # buffer size needs to be big enough to include the crossfade part
                 req_buffer_size = (
@@ -1040,13 +1036,14 @@ class StreamsController(CoreController):
     async def get_queue_item_stream(
         self,
         queue_item: QueueItem,
-        pcm_format: AudioFormat,
+        output_format: AudioFormat,
+        filter_params: list[str] | None = None,
     ) -> AsyncGenerator[bytes, None]:
-        """Get the audio stream for a single queue item as raw PCM audio."""
+        """Get the audio stream for a single queue item."""
         # collect all arguments for ffmpeg
         streamdetails = queue_item.streamdetails
         assert streamdetails
-        filter_params = []
+        filter_params = filter_params or []
 
         # handle volume normalization
         gain_correct: float | None = None
@@ -1078,21 +1075,11 @@ class StreamsController(CoreController):
             filter_params.append(f"volume={gain_correct}dB")
         streamdetails.volume_normalization_gain_correct = gain_correct
 
-        # if streamdetails.media_type == MediaType.RADIO or not streamdetails.duration:
-        #     # pad some silence before the radio/live stream starts to create some headroom
-        #     # for radio stations (or other live streams) that do not provide any look ahead buffer
-        #     # without this, some radio streams jitter a lot,
-        #     # especially with dynamic normalization,
-        #     # if the stream does not provide a look ahead buffer
-        #     async for silence in get_silence(4, pcm_format):
-        #         yield silence
-        #         del silence
-
         first_chunk_received = False
         async for chunk in get_media_stream(
             self.mass,
             streamdetails=streamdetails,
-            pcm_format=pcm_format,
+            output_format=output_format,
             filter_params=filter_params,
         ):
             if not first_chunk_received:
index 5b41c5bbb6d5d31a054946fab3e4765fde92d03b..35fd2e493b5c313587d60e92f431ca93807547c8 100644 (file)
@@ -32,9 +32,9 @@ from music_assistant_models.errors import (
     ProviderUnavailableError,
 )
 from music_assistant_models.media_items import AudioFormat
+from music_assistant_models.streamdetails import MultiPartPath
 
 from music_assistant.constants import (
-    CONF_ALLOW_AUDIO_CACHE,
     CONF_ENTRY_OUTPUT_LIMITER,
     CONF_OUTPUT_CHANNELS,
     CONF_VOLUME_NORMALIZATION,
@@ -54,7 +54,7 @@ from .dsp import filter_to_ffmpeg_params
 from .ffmpeg import FFMpeg, get_ffmpeg_stream
 from .playlists import IsHLSPlaylist, PlaylistItem, fetch_playlist, parse_m3u
 from .process import AsyncProcess, communicate
-from .util import detect_charset, has_enough_space
+from .util import detect_charset
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import CoreConfig, PlayerConfig
@@ -74,240 +74,9 @@ HTTP_HEADERS_ICY = {**HTTP_HEADERS, "Icy-MetaData": "1"}
 
 SLOW_PROVIDERS = ("tidal", "ytmusic", "apple_music")
 
-CACHE_CATEGORY_AUDIO_CACHE: Final[int] = 99
 CACHE_CATEGORY_RESOLVED_RADIO_URL: Final[int] = 100
 CACHE_PROVIDER: Final[str] = "audio"
-CACHE_FILES_IN_USE: set[str] = set()
-
-
-class StreamCache:
-    """
-    StreamCache.
-
-    Basic class to handle caching of audio streams to a (semi) temporary file.
-    Useful in case of slow or unreliable network connections, faster seeking,
-    or when the audio stream is slow itself.
-    """
-
-    def __init__(self, mass: MusicAssistant, streamdetails: StreamDetails) -> None:
-        """Initialize the StreamCache."""
-        self.mass = mass
-        self.streamdetails = streamdetails
-        self.logger = LOGGER.getChild("cache")
-        self._cache_file: str | None = None
-        self._fetch_task: asyncio.Task[None] | None = None
-        self._subscribers: int = 0
-        self._first_part_received = asyncio.Event()
-        self._all_data_written: bool = False
-        self._stream_error: str | None = None
-        self.org_path: str | None = streamdetails.path
-        self.org_stream_type: StreamType | None = streamdetails.stream_type
-        self.org_extra_input_args: list[str] | None = streamdetails.extra_input_args
-        self.org_audio_format = streamdetails.audio_format
-        streamdetails.audio_format = AudioFormat(
-            content_type=ContentType.NUT,
-            codec_type=streamdetails.audio_format.codec_type,
-            sample_rate=streamdetails.audio_format.sample_rate,
-            bit_depth=streamdetails.audio_format.bit_depth,
-            channels=streamdetails.audio_format.channels,
-        )
-        streamdetails.path = "-"
-        streamdetails.stream_type = StreamType.CACHE
-        streamdetails.can_seek = True
-        streamdetails.allow_seek = True
-        streamdetails.extra_input_args = []
-
-    async def create(self) -> None:
-        """Create the cache file (if needed)."""
-        if self._cache_file is None:
-            if cached_cache_path := await self.mass.cache.get(
-                key=self.streamdetails.uri,
-                provider=CACHE_PROVIDER,
-                category=CACHE_CATEGORY_AUDIO_CACHE,
-            ):
-                # we have a mapping stored for this uri, prefer that
-                self._cache_file = cached_cache_path
-                assert self._cache_file is not None  # for type checking
-                if await asyncio.to_thread(os.path.exists, self._cache_file):
-                    # cache file already exists from a previous session,
-                    # we can simply use that, there is nothing to create
-                    CACHE_FILES_IN_USE.add(self._cache_file)
-                    self._all_data_written = True
-                    return
-            else:
-                # create new cache file
-                cache_id = shortuuid.random(30)
-                self._cache_file = cache_file = os.path.join(
-                    self.mass.streams.audio_cache_dir, cache_id
-                )
-                await self.mass.cache.set(
-                    key=self.streamdetails.uri,
-                    data=cache_file,
-                    provider=CACHE_PROVIDER,
-                    category=CACHE_CATEGORY_AUDIO_CACHE,
-                )
-        # mark file as in-use to prevent it being deleted
-        CACHE_FILES_IN_USE.add(self._cache_file)
-        # start fetch task if its not already running
-        if self._fetch_task is None:
-            self._fetch_task = self.mass.create_task(self._create_cache_file())
-        # wait until the first part of the file is received
-        await self._first_part_received.wait()
-        if self._stream_error:
-            # an error occurred while creating the cache file
-            # remove the cache file and raise an error
-            raise AudioError(self._stream_error)
-
-    def release(self) -> None:
-        """Release the cache file."""
-        self._subscribers -= 1
-        if self._subscribers <= 0:
-            assert self._cache_file is not None  # for type checking
-            CACHE_FILES_IN_USE.discard(self._cache_file)
-
-    async def get_audio_stream(self) -> str | AsyncGenerator[bytes, None]:
-        """
-        Get the cached audio stream.
-
-        Returns a string with the path of the cachefile if the file is ready.
-        If the file is not yet ready, it will return an async generator that will
-        stream the (intermediate) audio data from the cache file.
-        """
-        self._subscribers += 1
-        assert self._cache_file is not None  # type guard
-        # mark file as in-use to prevent it being deleted
-        CACHE_FILES_IN_USE.add(self._cache_file)
-
-        async def _stream_from_cache() -> AsyncGenerator[bytes, None]:
-            chunksize = get_chunksize(self.streamdetails.audio_format, 1)
-            wait_loops = 0
-            assert self._cache_file is not None  # type guard
-            async with aiofiles.open(self._cache_file, "rb") as file:
-                while wait_loops < 2000:
-                    chunk = await file.read(chunksize)
-                    if chunk:
-                        yield chunk
-                        await asyncio.sleep(0)  # yield to eventloop
-                        del chunk
-                    elif self._all_data_written:
-                        # reached EOF
-                        break
-                    else:
-                        # data is not yet available, wait a bit
-                        await asyncio.sleep(0.05)
-                        # prevent an infinite loop in case of an error
-                        wait_loops += 1
-
-        if self._all_data_written:
-            # cache file is ready
-            return self._cache_file
-
-        # cache file does not exist at all (or is still being written)
-        await self.create()
-        return _stream_from_cache()
-
-    async def _create_cache_file(self) -> None:
-        time_start = time.time()
-        self.logger.debug("Creating audio cache for %s", self.streamdetails.uri)
-        assert self._cache_file is not None  # for type checking
-        CACHE_FILES_IN_USE.add(self._cache_file)
-        self._first_part_received.clear()
-        self._all_data_written = False
-        extra_input_args = ["-y", *(self.org_extra_input_args or [])]
-        audio_source: AsyncGenerator[bytes, None] | str | int
-        if self.org_stream_type == StreamType.CUSTOM:
-            provider = self.mass.get_provider(self.streamdetails.provider)
-            if TYPE_CHECKING:  # avoid circular import
-                assert isinstance(provider, MusicProvider)
-            audio_source = provider.get_audio_stream(
-                self.streamdetails,
-            )
-        elif self.org_stream_type == StreamType.ICY:
-            raise NotImplementedError("Caching of this streamtype is not supported!")
-        elif self.org_stream_type == StreamType.HLS:
-            if self.streamdetails.media_type == MediaType.RADIO:
-                raise NotImplementedError("Caching of this streamtype is not supported!")
-            assert self.org_path is not None  # for type checking
-            substream = await get_hls_substream(self.mass, self.org_path)
-            audio_source = substream.path
-        elif self.org_stream_type == StreamType.ENCRYPTED_HTTP:
-            assert self.org_path is not None  # for type checking
-            assert self.streamdetails.decryption_key is not None  # for type checking
-            audio_source = self.org_path
-            extra_input_args += ["-decryption_key", self.streamdetails.decryption_key]
-        elif self.org_stream_type == StreamType.MULTI_FILE:
-            audio_source = get_multi_file_stream(self.mass, self.streamdetails)
-        else:
-            assert self.org_path is not None  # for type checking
-            audio_source = self.org_path
-
-        # we always use ffmpeg to fetch the original audio source
-        # this may feel a bit redundant, but it's the most reliable way to fetch the audio
-        # because ffmpeg has all logic to handle different audio formats, codecs, etc.
-        # and it also accounts for complicated cases such as encrypted streams or
-        # m4a/mp4 streams with the moov atom at the end of the file.
-        # ffmpeg will produce a lossless copy of the original codec.
-        ffmpeg_proc = FFMpeg(
-            audio_input=audio_source,
-            input_format=self.org_audio_format,
-            output_format=self.streamdetails.audio_format,
-            extra_input_args=extra_input_args,
-            audio_output=self._cache_file,
-            collect_log_history=True,
-        )
-        try:
-            await ffmpeg_proc.start()
-            # wait until the first data is written to the cache file
-            while ffmpeg_proc.returncode is None:
-                await asyncio.sleep(0.1)
-                if not await asyncio.to_thread(os.path.exists, self._cache_file):
-                    continue
-                if await asyncio.to_thread(os.path.getsize, self._cache_file) > 64000:
-                    break
-
-            # set 'first part received' event to signal that the first part of the file is ready
-            # this is useful for the get_audio_stream method to know when it can start streaming
-            # we do guard for the returncode here, because if ffmpeg exited abnormally, we should
-            # not signal that the first part is ready
-            if ffmpeg_proc.returncode in (None, 0):
-                self._first_part_received.set()
-                self.logger.debug(
-                    "First part received for %s after %.2fs",
-                    self.streamdetails.uri,
-                    time.time() - time_start,
-                )
-            # wait until ffmpeg is done
-            await ffmpeg_proc.wait()
-
-            # raise an error if ffmpeg exited with a non-zero code
-            if ffmpeg_proc.returncode != 0:
-                ffmpeg_proc.logger.warning("\n".join(ffmpeg_proc.log_history))
-                raise AudioError(f"FFMpeg error {ffmpeg_proc.returncode}")
-
-            # set 'all data written' event to signal that the entire file is ready
-            self._all_data_written = True
-            self.logger.debug(
-                "Writing all data for %s done in %.2fs",
-                self.streamdetails.uri,
-                time.time() - time_start,
-            )
-        except BaseException as err:
-            self.logger.error("Error while creating cache for %s: %s", self.streamdetails.uri, err)
-            # make sure that the (corrupted/incomplete) cache file is removed
-            await self._remove_cache_file()
-            # unblock the waiting tasks by setting the event
-            # this will allow the tasks to continue and handle the error
-            self._stream_error = str(err) or err.__qualname__  # type: ignore [attr-defined]
-            self._first_part_received.set()
-        finally:
-            await ffmpeg_proc.close()
-
-    async def _remove_cache_file(self) -> None:
-        self._first_part_received.clear()
-        self._all_data_written = False
-        self._fetch_task = None
-        assert self._cache_file is not None  # for type checking
-        await remove_file(self._cache_file)
+STREAMDETAILS_EXPIRATION: Final[int] = 60 * 15  # 15 minutes
 
 
 async def crossfade_pcm_parts(
@@ -552,7 +321,6 @@ async def get_stream_details(
     This is called just-in-time when a PlayerQueue wants a MediaItem to be played.
     Do not try to request streamdetails too much in advance as this is expiring data.
     """
-    BYPASS_THROTTLER.set(True)
     time_start = time.time()
     LOGGER.debug("Getting streamdetails for %s", queue_item.uri)
     if seek_position and (queue_item.media_type == MediaType.RADIO or not queue_item.duration):
@@ -565,9 +333,12 @@ async def get_stream_details(
         raise MediaNotFoundError(
             f"Unable to retrieve streamdetails for {queue_item.name} ({queue_item.uri})"
         )
-    if queue_item.streamdetails and (utc() - queue_item.streamdetails.created_at).seconds < 1800:
+    if (
+        queue_item.streamdetails
+        and (utc() - queue_item.streamdetails.created_at).seconds < STREAMDETAILS_EXPIRATION
+    ):
         # already got a fresh/unused (or cached) streamdetails
-        # we assume that the streamdetails are valid for max 30 minutes
+        # we assume that the streamdetails are valid for max STREAMDETAILS_EXPIRATION seconds
         streamdetails = queue_item.streamdetails
     else:
         # retrieve streamdetails from provider
@@ -589,6 +360,7 @@ async def get_stream_details(
                 continue  # provider not available ?
             # get streamdetails from provider
             try:
+                BYPASS_THROTTLER.set(True)
                 streamdetails = await music_prov.get_stream_details(
                     prov_media.item_id, media_item.media_type
                 )
@@ -596,6 +368,8 @@ async def get_stream_details(
                 LOGGER.warning(str(err))
             else:
                 break
+            finally:
+                BYPASS_THROTTLER.set(False)
         else:
             msg = f"Unable to retrieve streamdetails for {queue_item.name} ({queue_item.uri})"
             raise MediaNotFoundError(msg)
@@ -605,7 +379,7 @@ async def get_stream_details(
             streamdetails.stream_type in (StreamType.ICY, StreamType.HLS, StreamType.HTTP)
             and streamdetails.media_type == MediaType.RADIO
         ):
-            assert streamdetails.path is not None  # for type checking
+            assert isinstance(streamdetails.path, str)  # for type checking
             resolved_url, stream_type = await resolve_radio_stream(mass, streamdetails.path)
             streamdetails.path = resolved_url
             streamdetails.stream_type = stream_type
@@ -644,121 +418,43 @@ async def get_stream_details(
         queue_item.uri,
         int((time.time() - time_start) * 1000),
     )
-
-    # determine if we may use caching for the audio stream
-    if streamdetails.enable_cache is None:
-        streamdetails.enable_cache = await _is_cache_allowed(mass, streamdetails)
-
-    # handle temporary cache support of audio stream
-    if streamdetails.enable_cache:
-        if streamdetails.cache is None:
-            streamdetails.cache = StreamCache(mass, streamdetails)
-        else:
-            streamdetails.cache = cast("StreamCache", streamdetails.cache)
-        # create cache (if needed) and wait until the cache is available
-        await streamdetails.cache.create()
-        LOGGER.debug(
-            "streamdetails cache ready for %s in %s milliseconds",
-            queue_item.uri,
-            int((time.time() - time_start) * 1000),
-        )
-
     return streamdetails
 
 
-async def _is_cache_allowed(mass: MusicAssistant, streamdetails: StreamDetails) -> bool:
-    """Check if caching is allowed for the given streamdetails."""
-    if streamdetails.media_type not in (
-        MediaType.TRACK,
-        MediaType.AUDIOBOOK,
-        MediaType.PODCAST_EPISODE,
-    ):
-        return False
-    if streamdetails.stream_type in (StreamType.ICY, StreamType.LOCAL_FILE, StreamType.UNKNOWN):
-        return False
-    if streamdetails.stream_type == StreamType.LOCAL_FILE:
-        # no need to cache local files
-        return False
-    allow_cache = mass.config.get_raw_core_config_value(
-        "streams", CONF_ALLOW_AUDIO_CACHE, mass.streams.allow_cache_default
-    )
-    if allow_cache == "disabled":
-        return False
-    if not await has_enough_space(mass.streams.audio_cache_dir, 5):
-        return False
-    if allow_cache == "always":
-        return True
-    # auto mode
-    if streamdetails.stream_type == StreamType.ENCRYPTED_HTTP:
-        # always prefer cache for encrypted streams
-        return True
-    if not streamdetails.duration:
-        # we can't determine filesize without duration so play it safe and dont allow cache
-        return False
-    estimated_filesize = get_chunksize(streamdetails.audio_format, streamdetails.duration)
-    if streamdetails.stream_type == StreamType.MULTI_FILE:
-        # prefer cache to speedup multi-file streams
-        # (if total filesize smaller than 2GB)
-        max_filesize = 2 * 1024 * 1024 * 1024
-    elif streamdetails.stream_type == StreamType.CUSTOM:
-        # prefer cache for custom streams (to speedup seeking)
-        max_filesize = 250 * 1024 * 1024  # 250MB
-    elif streamdetails.stream_type == StreamType.HLS:
-        # prefer cache for HLS streams (to speedup seeking)
-        max_filesize = 250 * 1024 * 1024  # 250MB
-    elif streamdetails.media_type in (
-        MediaType.AUDIOBOOK,
-        MediaType.PODCAST_EPISODE,
-    ):
-        # prefer cache for audiobooks and episodes (to speedup seeking)
-        max_filesize = 2 * 1024 * 1024 * 1024  # 2GB
-    elif streamdetails.provider in SLOW_PROVIDERS:
-        # prefer cache for slow providers
-        max_filesize = 2 * 1024 * 1024 * 1024  # 2GB
-    else:
-        max_filesize = 50 * 1024 * 1024
-
-    return estimated_filesize < max_filesize
-
-
 async def get_media_stream(
     mass: MusicAssistant,
     streamdetails: StreamDetails,
-    pcm_format: AudioFormat,
+    output_format: AudioFormat,
     filter_params: list[str] | None = None,
 ) -> AsyncGenerator[bytes, None]:
-    """Get PCM audio stream for given media details."""
+    """Get audio stream for given media details."""
     logger = LOGGER.getChild("media_stream")
     logger.log(VERBOSE_LOG_LEVEL, "Starting media stream for %s", streamdetails.uri)
     extra_input_args = streamdetails.extra_input_args or []
-    strip_silence_begin = streamdetails.strip_silence_begin
-    strip_silence_end = streamdetails.strip_silence_end
     if filter_params is None:
         filter_params = []
     if streamdetails.fade_in:
         filter_params.append("afade=type=in:start_time=0:duration=3")
-        strip_silence_begin = False
 
+    seek_position = streamdetails.seek_position
     # work out audio source for these streamdetails
+    audio_source: str | AsyncGenerator[bytes, None]
     stream_type = streamdetails.stream_type
-    if stream_type == StreamType.CACHE:
-        cache = cast("StreamCache", streamdetails.cache)
-        audio_source = await cache.get_audio_stream()
-    elif stream_type == StreamType.MULTI_FILE:
-        audio_source = get_multi_file_stream(mass, streamdetails)
-    elif stream_type == StreamType.CUSTOM:
+    if stream_type == StreamType.CUSTOM:
         music_prov = mass.get_provider(streamdetails.provider)
         if TYPE_CHECKING:  # avoid circular import
             assert isinstance(music_prov, MusicProvider)
         audio_source = music_prov.get_audio_stream(
             streamdetails,
-            seek_position=streamdetails.seek_position if streamdetails.can_seek else 0,
+            seek_position=seek_position if streamdetails.can_seek else 0,
         )
+        seek_position = 0 if streamdetails.can_seek else seek_position
     elif stream_type == StreamType.ICY:
-        assert streamdetails.path is not None  # for type checking
+        assert isinstance(streamdetails.path, str)  # for type checking
         audio_source = get_icy_radio_stream(mass, streamdetails.path, streamdetails)
+        seek_position = 0  # seeking not possible on radio streams
     elif stream_type == StreamType.HLS:
-        assert streamdetails.path is not None  # for type checking
+        assert isinstance(streamdetails.path, str)  # for type checking
         substream = await get_hls_substream(mass, streamdetails.path)
         audio_source = substream.path
         if streamdetails.media_type == MediaType.RADIO:
@@ -766,35 +462,32 @@ async def get_media_stream(
             # with ffmpeg, where they just stop after some minutes,
             # so we tell ffmpeg to loop around in this case.
             extra_input_args += ["-stream_loop", "-1", "-re"]
-    elif stream_type == StreamType.ENCRYPTED_HTTP:
-        assert streamdetails.path is not None  # for type checking
-        assert streamdetails.decryption_key is not None  # for type checking
-        audio_source = streamdetails.path
-        extra_input_args += ["-decryption_key", streamdetails.decryption_key]
     else:
-        assert streamdetails.path is not None  # for type checking
-        audio_source = streamdetails.path
+        # all other stream types (HTTP, FILE, etc)
+        if stream_type == StreamType.ENCRYPTED_HTTP:
+            assert streamdetails.decryption_key is not None  # for type checking
+            extra_input_args += ["-decryption_key", streamdetails.decryption_key]
+        if isinstance(streamdetails.path, list):
+            # multi part stream
+            audio_source = get_multi_file_stream(mass, streamdetails, seek_position)
+            seek_position = 0  # handled by get_multi_file_stream
+        else:
+            # regular single file/url stream
+            assert isinstance(streamdetails.path, str)  # for type checking
+            audio_source = streamdetails.path
 
     # handle seek support
-    if (
-        streamdetails.seek_position
-        and streamdetails.duration
-        and streamdetails.allow_seek
-        # allow seeking for custom streams,
-        # but only for custom streams that can't seek theirselves
-        and not (stream_type == StreamType.CUSTOM and streamdetails.can_seek)
-    ):
-        extra_input_args += ["-ss", str(int(streamdetails.seek_position))]
+    if seek_position and streamdetails.duration and streamdetails.allow_seek:
+        extra_input_args += ["-ss", str(int(seek_position))]
 
     bytes_sent = 0
-    chunk_number = 0
-    buffer: bytes = b""
     finished = False
     cancelled = False
+    first_chunk_received = False
     ffmpeg_proc = FFMpeg(
         audio_input=audio_source,
         input_format=streamdetails.audio_format,
-        output_format=pcm_format,
+        output_format=output_format,
         filter_params=filter_params,
         extra_input_args=extra_input_args,
         collect_log_history=True,
@@ -813,77 +506,27 @@ async def get_media_stream(
             streamdetails.uri,
             streamdetails.stream_type,
             streamdetails.volume_normalization_mode,
-            pcm_format.content_type.value,
+            output_format.content_type.value,
             ffmpeg_proc.proc.pid,
         )
-        # use 1 second chunks
-        chunk_size = pcm_format.pcm_sample_size
+        stream_start = mass.loop.time()
+
+        chunk_size = get_chunksize(output_format, 1)
         async for chunk in ffmpeg_proc.iter_chunked(chunk_size):
-            if chunk_number == 1:
+            if not first_chunk_received:
                 # At this point ffmpeg has started and should now know the codec used
                 # for encoding the audio.
+                first_chunk_received = True
                 streamdetails.audio_format.codec_type = ffmpeg_proc.input_format.codec_type
-
-            # for non-tracks we just yield all chunks directly
-            if streamdetails.media_type != MediaType.TRACK:
-                yield chunk
-                bytes_sent += len(chunk)
-                continue
-
-            chunk_number += 1
-            # determine buffer size dynamically
-            if chunk_number < 5 and strip_silence_begin:
-                req_buffer_size = int(pcm_format.pcm_sample_size * 5)
-            elif chunk_number > 240 and strip_silence_end:
-                req_buffer_size = int(pcm_format.pcm_sample_size * 10)
-            elif chunk_number > 120 and strip_silence_end:
-                req_buffer_size = int(pcm_format.pcm_sample_size * 8)
-            elif chunk_number > 60:
-                req_buffer_size = int(pcm_format.pcm_sample_size * 6)
-            elif chunk_number > 20 and strip_silence_end:
-                req_buffer_size = int(pcm_format.pcm_sample_size * 4)
-            else:
-                req_buffer_size = pcm_format.pcm_sample_size * 2
-
-            # always append to buffer
-            buffer += chunk
-            del chunk
-
-            if len(buffer) < req_buffer_size:
-                # buffer is not full enough, move on
-                continue
-
-            if strip_silence_begin:
-                # strip silence from begin of audio
-                strip_silence_begin = False
-                chunk = await strip_silence(  # noqa: PLW2901
-                    mass, buffer, pcm_format=pcm_format
+                logger.debug(
+                    "First chunk received after %s seconds",
+                    mass.loop.time() - stream_start,
                 )
-                bytes_sent += len(chunk)
-                yield chunk
-                buffer = b""
-                continue
-
-            #### OTHER: enough data in buffer, feed to output
-            while len(buffer) > req_buffer_size:
-                yield buffer[: pcm_format.pcm_sample_size]
-                bytes_sent += pcm_format.pcm_sample_size
-                buffer = buffer[pcm_format.pcm_sample_size :]
+            yield chunk
+            bytes_sent += len(chunk)
 
         # end of audio/track reached
         logger.log(VERBOSE_LOG_LEVEL, "End of stream reached.")
-        if strip_silence_end and buffer:
-            # strip silence from end of audio
-            buffer = await strip_silence(
-                mass,
-                buffer,
-                pcm_format=pcm_format,
-                reverse=True,
-            )
-        # send remaining bytes in buffer
-        bytes_sent += len(buffer)
-        yield buffer
-        del buffer
         # wait until stderr also completed reading
         await ffmpeg_proc.wait_with_timeout(5)
         if bytes_sent == 0:
@@ -905,7 +548,16 @@ async def get_media_stream(
         # always ensure close is called which also handles all cleanup
         await ffmpeg_proc.close()
         # try to determine how many seconds we've streamed
-        seconds_streamed = bytes_sent / pcm_format.pcm_sample_size if bytes_sent else 0
+        if output_format.content_type.is_pcm():
+            # for pcm output we can calculate this easily
+            seconds_streamed = bytes_sent / output_format.pcm_sample_size if bytes_sent else 0
+            streamdetails.seconds_streamed = seconds_streamed
+            # store accurate duration
+            if finished and not streamdetails.seek_position and seconds_streamed:
+                streamdetails.duration = int(seconds_streamed)
+        else:
+            # this is a less accurate estimate for compressed audio
+            seconds_streamed = bytes_sent / get_chunksize(output_format, 1)
         logger.debug(
             "stream %s (with code %s) for %s - seconds streamed: %s",
             "cancelled" if cancelled else "finished" if finished else "aborted",
@@ -913,15 +565,6 @@ async def get_media_stream(
             streamdetails.uri,
             seconds_streamed,
         )
-        streamdetails.seconds_streamed = seconds_streamed
-        # store accurate duration
-        if finished and not streamdetails.seek_position and seconds_streamed:
-            streamdetails.duration = int(seconds_streamed)
-
-        # release cache if needed
-        if cache := streamdetails.cache:
-            cache = cast("StreamCache", streamdetails.cache)
-            cache.release()
 
         # parse loudnorm data if we have that collected (and enabled)
         if (
@@ -948,14 +591,15 @@ async def get_media_stream(
                         media_type=streamdetails.media_type,
                     )
                 )
-        elif (
+        # schedule loudness analysis if needed
+        if (
             streamdetails.loudness is None
             and streamdetails.volume_normalization_mode
             not in (
                 VolumeNormalizationMode.DISABLED,
                 VolumeNormalizationMode.FIXED_GAIN,
             )
-            and (finished or (seconds_streamed >= 30))
+            and (finished or (seconds_streamed >= 300))
         ):
             # dynamic mode not allowed and no measurement known, we need to analyze the audio
             # add background task to start analyzing the audio
@@ -1040,7 +684,9 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, Str
     if cache := await mass.cache.get(
         key=url, provider=CACHE_PROVIDER, category=CACHE_CATEGORY_RESOLVED_RADIO_URL
     ):
-        return cast("tuple[str, StreamType]", cache)
+        if TYPE_CHECKING:  # for type checking
+            cache = cast("tuple[str, str]", cache)
+        return (cache[0], StreamType(cache[1]))
     stream_type = StreamType.HTTP
     resolved_url = url
     timeout = ClientTimeout(total=0, connect=10, sock_read=5)
@@ -1309,7 +955,20 @@ async def get_multi_file_stream(
     Arguments:
     seek_position: The position to seek to in seconds
     """
-    files_list: list[str] = streamdetails.data
+    files_list: list[str] = []
+    if not isinstance(streamdetails.path, list):
+        raise InvalidDataError("Multi-file streamdetails requires a list of MultiPartPath")
+    skipped_duration = 0.0
+    for part in streamdetails.path:
+        if not isinstance(part, MultiPartPath):
+            raise InvalidDataError("Multi-file streamdetails requires a list of MultiPartPath")
+        if seek_position and part.duration and (skipped_duration + part.duration) < seek_position:
+            skipped_duration += part.duration
+            continue
+        files_list.append(part.path)
+    if seek_position:
+        seek_position -= int(skipped_duration)
+
     # concat input files
     temp_file = f"/tmp/{shortuuid.random(20)}.txt"  # noqa: S108
     async with aiofiles.open(temp_file, "w") as f:
@@ -1355,18 +1014,11 @@ async def get_preview_stream(
     if TYPE_CHECKING:  # avoid circular import
         assert isinstance(music_prov, MusicProvider)
     streamdetails = await music_prov.get_stream_details(item_id, media_type)
-
-    audio_input: AsyncGenerator[bytes, None] | str
-    if streamdetails.stream_type == StreamType.CUSTOM:
-        audio_input = music_prov.get_audio_stream(streamdetails, 30)
-    else:
-        assert streamdetails.path is not None  # for type checking
-        audio_input = streamdetails.path
-    async for chunk in get_ffmpeg_stream(
-        audio_input=audio_input,
-        input_format=streamdetails.audio_format,
+    streamdetails.extra_input_args += ["-t", "30"]  # cut after 30 seconds
+    async for chunk in get_media_stream(
+        mass=mass,
+        streamdetails=streamdetails,
         output_format=AudioFormat(content_type=ContentType.AAC),
-        extra_input_args=["-to", "30"],
     ):
         yield chunk
 
@@ -1435,7 +1087,7 @@ async def resample_pcm_audio(
 
 def get_chunksize(
     fmt: AudioFormat,
-    seconds: int = 1,
+    seconds: float = 1,
 ) -> int:
     """Get a default chunk/file size for given contenttype in bytes."""
     pcm_size = int(fmt.sample_rate * (fmt.bit_depth / 8) * fmt.channels * seconds)
@@ -1614,30 +1266,33 @@ async def analyze_loudness(
         "-t",
         "600",
     ]
-    if streamdetails.stream_type == StreamType.CACHE:
-        cache = cast("StreamCache", streamdetails.cache)
-        audio_source = await cache.get_audio_stream()
-    elif streamdetails.stream_type == StreamType.MULTI_FILE:
-        audio_source = get_multi_file_stream(mass, streamdetails)
-    elif streamdetails.stream_type == StreamType.CUSTOM:
+    # work out audio source for these streamdetails
+    stream_type = streamdetails.stream_type
+    audio_source: str | AsyncGenerator[bytes, None]
+    if stream_type == StreamType.CUSTOM:
         music_prov = mass.get_provider(streamdetails.provider)
         if TYPE_CHECKING:  # avoid circular import
             assert isinstance(music_prov, MusicProvider)
-        audio_source = music_prov.get_audio_stream(
-            streamdetails,
-        )
-    elif streamdetails.stream_type == StreamType.HLS:
-        assert streamdetails.path is not None  # for type checking
+        audio_source = music_prov.get_audio_stream(streamdetails)
+    elif stream_type == StreamType.ICY:
+        assert isinstance(streamdetails.path, str)  # for type checking
+        audio_source = get_icy_radio_stream(mass, streamdetails.path, streamdetails)
+    elif stream_type == StreamType.HLS:
+        assert isinstance(streamdetails.path, str)  # for type checking
         substream = await get_hls_substream(mass, streamdetails.path)
         audio_source = substream.path
-    elif streamdetails.stream_type == StreamType.ENCRYPTED_HTTP:
-        assert streamdetails.path is not None  # for type checking
-        assert streamdetails.decryption_key is not None  # for type checking
-        audio_source = streamdetails.path
-        extra_input_args += ["-decryption_key", streamdetails.decryption_key]
     else:
-        assert streamdetails.path is not None  # for type checking
-        audio_source = streamdetails.path
+        # all other stream types (HTTP, FILE, etc)
+        if stream_type == StreamType.ENCRYPTED_HTTP:
+            assert streamdetails.decryption_key is not None  # for type checking
+            extra_input_args += ["-decryption_key", streamdetails.decryption_key]
+        if isinstance(streamdetails.path, list):
+            # multi part stream - just use a single file for the measurement
+            audio_source = streamdetails.path[1].path
+        else:
+            # regular single file/url stream
+            assert isinstance(streamdetails.path, str)  # for type checking
+            audio_source = streamdetails.path
 
     # calculate BS.1770 R128 integrated loudness with ffmpeg
     async with FFMpeg(
@@ -1677,11 +1332,6 @@ async def analyze_loudness(
                 streamdetails.uri,
                 loudness,
             )
-        finally:
-            # release cache if needed
-            if cache := streamdetails.cache:
-                cache = cast("StreamCache", streamdetails.cache)
-                cache.release()
 
 
 def _get_normalization_mode(
index d8c7e34da4b67d43c914944c401de4458ec6bb58..cfc4ffccd2e9deef5fe4468b21d09bfd781bbe8c 100644 (file)
@@ -255,6 +255,10 @@ def get_ffmpeg_args(  # noqa: PLR0915
         "-ignore_unknown",
         "-protocol_whitelist",
         "file,hls,http,https,tcp,tls,crypto,pipe,data,fd,rtp,udp,concat",
+        "-probesize",
+        "8096",
+        "-analyzeduration",
+        "500000",  # 1 seconds should be enough to detect the format
     ]
     # collect input args
     if "-f" in extra_input_args:
index 837d6f47af01b8457a923f856167f7d7b863c8e5..1fa645c614723b4156a5fd65b3d0772384f9b4c2 100644 (file)
@@ -116,7 +116,7 @@ def parse_pls(pls_data: str) -> list[PlaylistItem]:
     except configparser.Error as err:
         raise InvalidDataError("Can't parse playlist") from err
 
-    if "playlist" not in pls_parser or pls_parser["playlist"].getint("Version") != 2:
+    if "playlist" not in pls_parser:
         raise InvalidDataError("Invalid playlist")
 
     try:
index 13ae8ed172834cc3ac87f32d05b87ea25e2ac415..32b76dfa1a68ca7ab6aa06ed364431b018025886 100644 (file)
@@ -18,7 +18,7 @@ import numpy.typing as npt
 import shortuuid
 
 from music_assistant.constants import VERBOSE_LOG_LEVEL
-from music_assistant.helpers.audio import crossfade_pcm_parts
+from music_assistant.helpers.audio import crossfade_pcm_parts, strip_silence
 from music_assistant.helpers.process import communicate
 from music_assistant.helpers.util import remove_file
 from music_assistant.models.smart_fades import (
@@ -258,6 +258,21 @@ class SmartFadesMixer:
             # Note that this should not happen since we check this before calling mix()
             # but just to be sure...
             return fade_out_part + fade_in_part
+
+        # strip silence from end of audio of fade_out_part
+        fade_out_part = await strip_silence(
+            self.mass,
+            fade_out_part,
+            pcm_format=pcm_format,
+            reverse=True,
+        )
+        # strip silence from begin of audio of fade_in_part
+        fade_in_part = await strip_silence(
+            self.mass,
+            fade_in_part,
+            pcm_format=pcm_format,
+            reverse=False,
+        )
         if mode == SmartFadesMode.STANDARD_CROSSFADE:
             # crossfade with standard crossfade
             return await self._default_crossfade(
index 0a9bd084d8b2b77ea9267fc3b1ebc1e792025e7d..5a67e908380de990156bda22750e6d428a8f4a2f 100644 (file)
@@ -47,7 +47,7 @@ from music_assistant_models.enums import (
     ProviderFeature,
     StreamType,
 )
-from music_assistant_models.errors import AudioError, LoginFailed, MediaNotFoundError
+from music_assistant_models.errors import LoginFailed, MediaNotFoundError
 from music_assistant_models.media_items import (
     Audiobook,
     AudioFormat,
@@ -58,10 +58,9 @@ from music_assistant_models.media_items import (
     UniqueList,
 )
 from music_assistant_models.media_items.media_item import RecommendationFolder
-from music_assistant_models.streamdetails import StreamDetails
+from music_assistant_models.streamdetails import MultiPartPath, StreamDetails
 
 from music_assistant.controllers.cache import use_cache
-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,
@@ -88,7 +87,6 @@ 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
@@ -543,6 +541,8 @@ for more details.
 
     async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
         """Get stream of item."""
+        # ensure we have a valid token
+        await self.reauthenticate()
         if media_type == MediaType.PODCAST_EPISODE:
             return await self._get_stream_details_episode(item_id)
         elif media_type == MediaType.AUDIOBOOK:
@@ -566,116 +566,24 @@ for more details.
         if abs_audiobook.media.tracks[0].metadata is not None:
             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}"
+            file_parts.append(MultiPartPath(path=stream_url, duration=track.duration))
+
         return StreamDetails(
             provider=self.lookup_key,
             item_id=abs_audiobook.id_,
             audio_format=AudioFormat(content_type=content_type),
             media_type=MediaType.AUDIOBOOK,
-            stream_type=StreamType.CUSTOM,
+            stream_type=StreamType.HTTP,
             duration=int(abs_audiobook.media.duration),
             data=tracks,
             can_seek=True,
             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
-        """
-
-        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.
 
@@ -695,6 +603,8 @@ for more details.
         content_type = ContentType.UNKNOWN
         if abs_episode.audio_track.metadata is not None:
             content_type = ContentType.try_parse(abs_episode.audio_track.metadata.ext)
+        base_url = str(self.config.get_value(CONF_URL))
+        stream_url = f"{base_url}{abs_episode.audio_track.content_url}?token={self._client.token}"
         return StreamDetails(
             provider=self.lookup_key,
             item_id=podcast_id,
@@ -702,10 +612,10 @@ for more details.
                 content_type=content_type,
             ),
             media_type=MediaType.PODCAST_EPISODE,
-            stream_type=StreamType.CUSTOM,
+            stream_type=StreamType.HTTP,
             can_seek=True,
             allow_seek=True,
-            data=[abs_episode.audio_track],
+            path=stream_url,
         )
 
     @handle_refresh_token
index ee35ff185768f31db84ab31a56f91630fc63294d..92548e7e487416e4daced8d7a8c251f71df10a2e 100644 (file)
@@ -278,7 +278,6 @@ class BuiltinPlayer(Player):
             bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
             channels=DEFAULT_PCM_FORMAT.channels,
         )
-
         async for chunk in get_ffmpeg_stream(
             audio_input=self.mass.streams.get_queue_flow_stream(
                 queue=queue,
index b116c8e792b57795d89dfa85e9ec6c7bbb57b8b4..557ce34835a03a143c218998308712ace7fa3757 100644 (file)
@@ -45,7 +45,7 @@ from music_assistant_models.media_items import (
     UniqueList,
     is_track,
 )
-from music_assistant_models.streamdetails import StreamDetails
+from music_assistant_models.streamdetails import MultiPartPath, StreamDetails
 
 from music_assistant.constants import (
     CONF_PATH,
@@ -1705,9 +1705,12 @@ class LocalFileSystemProvider(MusicProvider):
                 item_id=item_id,
                 audio_format=prov_mapping.audio_format,
                 media_type=MediaType.AUDIOBOOK,
-                stream_type=StreamType.MULTI_FILE,
+                stream_type=StreamType.LOCAL_FILE,
                 duration=duration,
-                data=[self.get_absolute_path(x[0]) for x in file_based_chapters],
+                path=[
+                    MultiPartPath(path=self.get_absolute_path(path), duration=duration)
+                    for path, duration in file_based_chapters
+                ],
                 allow_seek=True,
             )
 
index 3f2e293cbf5dcfd5a1db5acbcca4e86097c2384a..56a699d0d045130b2d8db55641d9e6973d7d59f3 100644 (file)
@@ -619,7 +619,12 @@ class OpenSonicProvider(MusicProvider):
             allow_seek=True,
             can_seek=self._seek_support,
             media_type=media_type,
-            audio_format=AudioFormat(content_type=ContentType.try_parse(mime_type)),
+            audio_format=AudioFormat(
+                content_type=ContentType.try_parse(mime_type),
+                sample_rate=item.sampling_rate if item.sampling_rate else 44100,
+                bit_depth=item.bit_depth if item.bit_depth else 16,
+                channels=item.channel_count if item.channel_count else 2,
+            ),
             stream_type=StreamType.CUSTOM,
             duration=item.duration if item.duration else 0,
         )
index 9ec43933b8f3c8ba8d2888390730332383c2c8ef..f1d73c73fae156ceed926f10b0c6ced9dff3e93d 100644 (file)
@@ -25,7 +25,7 @@ dependencies = [
   "ifaddr==0.2.0",
   "mashumaro==3.16",
   "music-assistant-frontend==2.16.5",
-  "music-assistant-models==1.1.60",
+  "music-assistant-models==1.1.61",
   "mutagen==1.47.0",
   "orjson==3.11.3",
   "pillow==11.3.0",
index 85793f190d586d81ba419436e8c297d799fa0ff5..b600cfcb0ca07e2fd0e3d973d8355945a6df4a6b 100644 (file)
@@ -35,7 +35,7 @@ llvmlite==0.44.0
 lyricsgenius==3.7.2
 mashumaro==3.16
 music-assistant-frontend==2.16.5
-music-assistant-models==1.1.60
+music-assistant-models==1.1.61
 mutagen==1.47.0
 numpy==2.2.6
 orjson==3.11.3