# 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"]
# 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" \
chunk_number = 0
buffer: bytes = b""
finished = False
+ cancelled = False
ffmpeg_proc = FFMpeg(
audio_input=audio_source,
input_format=streamdetails.audio_format,
# 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(
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
if streamdetails.loudness and preference not in (
VolumeNormalizationMode.DISABLED,
VolumeNormalizationMode.FIXED_GAIN,
+ VolumeNormalizationMode.DYNAMIC,
):
return VolumeNormalizationMode.MEASUREMENT_ONLY
import asyncio
import contextlib
+import logging
import os
import time
from typing import TYPE_CHECKING, Any, cast
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
self._librespot_bin,
"--cache",
self.cache_dir,
- "--cache-size-limit",
- "1G",
+ "--disable-audio-cache",
"--passthrough",
"--bitrate",
"320",
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."""