Switch to ffmpeg 7.1 + other fixes for audio streaming (#1882)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 16 Jan 2025 19:13:41 +0000 (20:13 +0100)
committerGitHub <noreply@github.com>
Thu, 16 Jan 2025 19:13:41 +0000 (20:13 +0100)
Co-authored-by: Kostas Chatzikokolakis <kostas@chatzi.org>
Dockerfile
Dockerfile.base
music_assistant/helpers/audio.py
music_assistant/providers/airplay/bin/cliraop-linux-aarch64
music_assistant/providers/airplay/bin/cliraop-linux-x86_64
music_assistant/providers/spotify/__init__.py

index 2d2412bd371313f7bcf64d5bf85063c5d2d56ff9..89d66a042342e3821d7d4dac77bcac1466f23451 100644 (file)
@@ -1,48 +1,73 @@
 # syntax=docker/dockerfile:1
 
-# FINAL docker image for music assistant server
-# This image is based on the base image and installs
-# the music assistant server from our built wheel on top.
-
+# Builder image. It builds the venv that will be copied to the final image
+#
 ARG BASE_IMAGE_VERSION=latest
+FROM ghcr.io/music-assistant/base:$BASE_IMAGE_VERSION AS builder
 
-FROM ghcr.io/music-assistant/base:$BASE_IMAGE_VERSION
+# create venv which will be copied to the final image
+ENV VIRTUAL_ENV=/app/venv
+RUN uv venv $VIRTUAL_ENV
 
-ARG MASS_VERSION
-ARG TARGETPLATFORM
 ADD dist dist
+COPY requirements_all.txt .
 
-# pre-install ALL requirements
+# pre-install ALL requirements into the venv
 # comes at a cost of a slightly larger image size but is faster to start
 # because we do not have to install dependencies at runtime
-COPY requirements_all.txt .
 RUN uv pip install \
-    --no-cache \
+    --find-links "https://wheels.home-assistant.io/musllinux/" \
     -r requirements_all.txt
 
 # Install Music Assistant from prebuilt wheel
+ARG MASS_VERSION
 RUN uv pip install \
     --no-cache \
+    --find-links "https://wheels.home-assistant.io/musllinux/" \
     "music-assistant@dist/music_assistant-${MASS_VERSION}-py3-none-any.whl"
 
+# we need to set (very permissive) permissions to the workdir
+# and /tmp to allow running the container as non-root
+# IMPORTANT: chmod here, NOT on the final image, to avoid creating extra layers and increase size!
+#
+RUN chmod -R 777 /app
+
+##################################################################################################
+
+# FINAL docker image for music assistant server
+
+FROM ghcr.io/music-assistant/base:$BASE_IMAGE_VERSION
+
+ENV VIRTUAL_ENV=/app/venv
+ENV PATH="$VIRTUAL_ENV/bin:$PATH"
+
+# copy the already build /app dir
+COPY --from=builder /app /app
+
+# the /app contents have correct permissions but for some reason /app itself does not.
+# so apply again, but ONLY to the dir (otherwise we increase the size)
+RUN chmod 777 /app
+
 # Set some labels
+ARG MASS_VERSION
+ARG TARGETPLATFORM
 LABEL \
     org.opencontainers.image.title="Music Assistant Server" \
-    org.opencontainers.image.description="Music Assistant Server/Core" \
+    org.opencontainers.image.description="Music Assistant is a free, opensource Media library manager that connects to your streaming services and a wide range of connected speakers. The server is the beating heart, the core of Music Assistant and must run on an always-on device like a Raspberry Pi, a NAS or an Intel NUC or alike." \
     org.opencontainers.image.source="https://github.com/music-assistant/server" \
     org.opencontainers.image.authors="The Music Assistant Team" \
-    org.opencontainers.image.documentation="https://github.com/orgs/music-assistant/discussions" \
+    org.opencontainers.image.documentation="https://music-assistant.io" \
     org.opencontainers.image.licenses="Apache License 2.0" \
     io.hass.version="${MASS_VERSION}" \
     io.hass.type="addon" \
     io.hass.name="Music Assistant Server" \
-    io.hass.description="Music Assistant Server/Core" \
+    io.hass.description="Music Assistant Server" \
     io.hass.platform="${TARGETPLATFORM}" \
     io.hass.type="addon"
 
-RUN rm -rf dist
-
 VOLUME [ "/data" ]
 EXPOSE 8095
 
+WORKDIR $VIRTUAL_ENV
+
 ENTRYPOINT ["mass", "--config", "/data"]
index 193c5db35734e1cbfd7b1996823a4df15451af5e..03a480dde6c17c071c4d272eb7429f9ecc6d5776 100644 (file)
@@ -1,64 +1,41 @@
 # syntax=docker/dockerfile:1
 
 # BASE docker image for music assistant container
-# Based on Debian Trixie (testing) because we need a newer version of ffmpeg (and snapcast)
-# TODO: Switch back to regular python stable debian image + manually build ffmpeg and snapcast ?
-FROM debian:trixie-slim
-
-ARG TARGETPLATFORM
+# This image forms the base for the final image and is not meant to be used directly
+# NOTE that the dev add-on is also based on this base image
 
+FROM python:3.12-alpine3.21
 
 RUN set -x \
-    && apt-get update \
-    && apt-get install -y --no-install-recommends \
+    && apk add --no-cache \
         ca-certificates \
-        curl \
-        git \
-        wget \
+        jemalloc \
         tzdata \
-        python3 \
-        python3-venv \
-        python3-pip \
-        libsox-fmt-all \
-        libsox3 \
-        ffmpeg \
-        sox \
-        openssl \
+        # cifs utils and libnfs are needed for smb and nfs support (file provider)
         cifs-utils \
-        libnfs-utils \
-        libjemalloc2 \
-        snapserver \
-    # cleanup
-    && rm -rf /tmp/* \
-    && rm -rf /var/lib/apt/lists/*
+        libnfs \
+        # openssl-dev is needed for airplay
+        openssl-dev  \
+    # install snapcast from community repo (because we need 0.28+)
+    && apk add --no-cache snapcast --repository=https://dl-cdn.alpinelinux.org/alpine/v3.20/community
 
+# Get static ffmpeg builds from https://hub.docker.com/r/mwader/static-ffmpeg/
+COPY --from=mwader/static-ffmpeg:7.1 /ffmpeg /usr/local/bin/
+COPY --from=mwader/static-ffmpeg:7.1 /ffprobe /usr/local/bin/
 
 # Copy widevine client files to container
 RUN mkdir -p /usr/local/bin/widevine_cdm
 COPY widevine_cdm/* /usr/local/bin/widevine_cdm/
 
-WORKDIR /app
-
-# Enable jemalloc
-RUN \
-    export LD_PRELOAD="$(find /usr/lib/ -name *libjemalloc.so.2)" \
-    export MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000"
+# ensure UV is installed
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
 
-# create python venv
-ENV VIRTUAL_ENV=/app/venv
-RUN python3 -m venv $VIRTUAL_ENV
-ENV PATH="$VIRTUAL_ENV/bin:$PATH"
-RUN pip install --upgrade pip \
-    && pip install uv==0.4.17
+# JEMalloc for more efficient memory management
+ENV LD_PRELOAD="/usr/lib/libjemalloc.so.2"
 
 # we need to set (very permissive) permissions to the workdir
 # and /tmp to allow running the container as non-root
-# NOTE that home assistant add-ons always run as root (and use apparmor)
-# so we can't specify a user here
-RUN chmod -R 777 /app \
-    && chmod -R 777 /tmp
-
-WORKDIR $VIRTUAL_ENV
+RUN chmod -R 777 /tmp
 
 LABEL \
     org.opencontainers.image.title="Music Assistant Base Image" \
index 71e9c40ff9d2a5187f16365071c74d0cbffbf823..f6170da019bae049cc28e8a895cc81fc5fdc5165 100644 (file)
@@ -361,6 +361,7 @@ async def get_media_stream(
     chunk_number = 0
     buffer: bytes = b""
     finished = False
+    cancelled = False
     ffmpeg_proc = FFMpeg(
         audio_input=audio_source,
         input_format=streamdetails.audio_format,
@@ -447,23 +448,22 @@ async def get_media_stream(
         # wait until stderr also completed reading
         await ffmpeg_proc.wait_with_timeout(5)
         finished = True
-    except Exception as err:
-        if isinstance(err, asyncio.CancelledError):
+    except (Exception, GeneratorExit) as err:
+        if isinstance(err, asyncio.CancelledError | GeneratorExit):
             # we were cancelled, just raise
+            cancelled = True
             raise
         logger.error("Error while streaming %s: %s", streamdetails.uri, err)
         streamdetails.stream_error = True
     finally:
-        if not finished:
-            logger.log(VERBOSE_LOG_LEVEL, "Closing ffmpeg...")
-            await ffmpeg_proc.close()
+        # 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 ffmpeg_proc.returncode != 0:
-            # dump the last 25 lines of the log in case of an unclean exit
-            log_tail = "\n" + "\n".join(list(ffmpeg_proc.log_history)[-25:])
-            logger.debug(log_tail)
+        if not cancelled and ffmpeg_proc.returncode != 0:
+            # dump the last 5 lines of the log in case of an unclean exit
+            log_tail = "\n" + "\n".join(list(ffmpeg_proc.log_history)[-5:])
         else:
             log_tail = ""
         logger.debug(
@@ -505,9 +505,14 @@ async def get_media_stream(
                         media_type=streamdetails.media_type,
                     )
                 )
-        elif streamdetails.loudness is None and streamdetails.volume_normalization_mode not in (
-            VolumeNormalizationMode.DISABLED,
-            VolumeNormalizationMode.FIXED_GAIN,
+        elif (
+            streamdetails.loudness is None
+            and streamdetails.volume_normalization_mode
+            not in (
+                VolumeNormalizationMode.DISABLED,
+                VolumeNormalizationMode.FIXED_GAIN,
+            )
+            and (finished or (seconds_streamed >= 30))
         ):
             # dynamic mode not allowed and no measurement known, we need to analyze the audio
             # add background task to start analyzing the audio
@@ -1139,6 +1144,7 @@ def _get_normalization_mode(
     if streamdetails.loudness and preference not in (
         VolumeNormalizationMode.DISABLED,
         VolumeNormalizationMode.FIXED_GAIN,
+        VolumeNormalizationMode.DYNAMIC,
     ):
         return VolumeNormalizationMode.MEASUREMENT_ONLY
 
index 60c107d48de7459dc07185f29d588ab387789756..21410d3fc0cd268b7d6c6be6bbb6eb6b5635e411 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 differ
index 67e823ad48101c0911a287a40af8d1410265b81b..95424e6f8fd0614787d1e5d8fd88e93820d20eb1 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 differ
index 268f4d63f25c6f7d3dc2e08adce572e0ed319858..a59f85eded21b0b9a19c0ede70f05f4dfe8bea08 100644 (file)
@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import asyncio
 import contextlib
+import logging
 import os
 import time
 from typing import TYPE_CHECKING, Any, cast
@@ -47,7 +48,7 @@ from music_assistant.helpers.auth import AuthenticationHelper
 from music_assistant.helpers.json import json_loads
 from music_assistant.helpers.process import AsyncProcess, check_output
 from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries
-from music_assistant.helpers.util import lock, parse_title_and_version
+from music_assistant.helpers.util import TimedAsyncGenerator, lock, parse_title_and_version
 from music_assistant.models.music_provider import MusicProvider
 
 from .helpers import get_librespot_binary
@@ -570,8 +571,7 @@ class SpotifyProvider(MusicProvider):
             self._librespot_bin,
             "--cache",
             self.cache_dir,
-            "--cache-size-limit",
-            "1G",
+            "--disable-audio-cache",
             "--passthrough",
             "--bitrate",
             "320",
@@ -586,20 +586,42 @@ class SpotifyProvider(MusicProvider):
         if seek_position:
             args += ["--start-position", str(int(seek_position))]
         chunk_size = get_chunksize(streamdetails.audio_format)
-        stderr = None if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL) else False
+        stderr = bool(self.logger.isEnabledFor(logging.DEBUG))
         bytes_received = 0
-        async with AsyncProcess(
+        log_lines: list[str] = []
+
+        librespot_proc: AsyncProcess = AsyncProcess(
             args,
             stdout=True,
             stderr=stderr,
             name="librespot",
-        ) as librespot_proc:
-            async for chunk in librespot_proc.iter_any(chunk_size):
+        )
+        try:
+            await librespot_proc.start()
+
+            async def _read_stderr():
+                logger = self.logger.getChild("librespot")
+                async for line in librespot_proc.iter_stderr():
+                    log_lines.append(line)
+                    logger.log(VERBOSE_LOG_LEVEL, line)
+
+            if stderr:
+                log_reader = asyncio.create_task(_read_stderr())
+
+            async for chunk in TimedAsyncGenerator(librespot_proc.iter_any(chunk_size), 20):
                 yield chunk
                 bytes_received += len(chunk)
+            if stderr:
+                await log_reader
+
+            if bytes_received == 0:
+                raise AudioError("No audio received from librespot")
 
-        if librespot_proc.returncode != 0 or bytes_received == 0:
-            raise AudioError(f"Failed to stream track {spotify_uri}")
+        finally:
+            await librespot_proc.close()
+            if not bytes_received:
+                log_lines = "\n".join(log_lines)
+                self.logger.error("Error while streaming track %s\n%s", spotify_uri, log_lines)
 
     def _parse_artist(self, artist_obj):
         """Parse spotify artist object to generic layout."""