From: Marcel van der Veldt Date: Thu, 16 Jan 2025 19:13:41 +0000 (+0100) Subject: Switch to ffmpeg 7.1 + other fixes for audio streaming (#1882) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=679c62bad18892a5cb7e8badbda7381394ba0fee;p=music-assistant-server.git Switch to ffmpeg 7.1 + other fixes for audio streaming (#1882) Co-authored-by: Kostas Chatzikokolakis --- diff --git a/Dockerfile b/Dockerfile index 2d2412bd..89d66a04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.base b/Dockerfile.base index 193c5db3..03a480dd 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -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" \ diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index 71e9c40f..f6170da0 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -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 diff --git a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 index 60c107d4..21410d3f 100755 Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 differ diff --git a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 index 67e823ad..95424e6f 100755 Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 differ diff --git a/music_assistant/providers/spotify/__init__.py b/music_assistant/providers/spotify/__init__.py index 268f4d63..a59f85ed 100644 --- a/music_assistant/providers/spotify/__init__.py +++ b/music_assistant/providers/spotify/__init__.py @@ -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."""