Skip to content

Commit

Permalink
Switch to ffmpeg 7.1 + other fixes for audio streaming (#1882)
Browse files Browse the repository at this point in the history
Co-authored-by: Kostas Chatzikokolakis <kostas@chatzi.org>
  • Loading branch information
marcelveldt and chatziko authored Jan 16, 2025
1 parent 7e0042d commit 679c62b
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 78 deletions.
55 changes: 40 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
61 changes: 19 additions & 42 deletions Dockerfile.base
Original file line number Diff line number Diff line change
@@ -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" \
Expand Down
30 changes: 18 additions & 12 deletions music_assistant/helpers/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Binary file modified music_assistant/providers/airplay/bin/cliraop-linux-aarch64
Binary file not shown.
Binary file modified music_assistant/providers/airplay/bin/cliraop-linux-x86_64
Binary file not shown.
40 changes: 31 additions & 9 deletions music_assistant/providers/spotify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import asyncio
import contextlib
import logging
import os
import time
from typing import TYPE_CHECKING, Any, cast
Expand Down Expand Up @@ -47,7 +48,7 @@
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
Expand Down Expand Up @@ -570,8 +571,7 @@ async def get_audio_stream(
self._librespot_bin,
"--cache",
self.cache_dir,
"--cache-size-limit",
"1G",
"--disable-audio-cache",
"--passthrough",
"--bitrate",
"320",
Expand All @@ -586,20 +586,42 @@ async def get_audio_stream(
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."""
Expand Down

0 comments on commit 679c62b

Please sign in to comment.