From: Mischa Siekmann <45062894+gnumpi@users.noreply.github.com> Date: Mon, 9 Feb 2026 07:34:16 +0000 (+0100) Subject: Snapcast: Introduce fallback Snapcast setup for dev environments (#3108) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=9c30b086d1b9b625ca91a852c9e1ee190d9470f9;p=music-assistant-server.git Snapcast: Introduce fallback Snapcast setup for dev environments (#3108) * snapcast: introduce fallback config and plugin-directory for dev environments * snapcast: suggested copilot fixes * snapcast: another comment with a local path in the snapserver.conf * merge fixes --- diff --git a/music_assistant/providers/snapcast/constants.py b/music_assistant/providers/snapcast/constants.py index 4764dcad..8c897c72 100644 --- a/music_assistant/providers/snapcast/constants.py +++ b/music_assistant/providers/snapcast/constants.py @@ -33,6 +33,11 @@ CONF_ENTRY_SAMPLE_RATES_SNAPCAST = create_sample_rates_config_entry( DEFAULT_SNAPSERVER_IP = "127.0.0.1" DEFAULT_SNAPSERVER_PORT = 1705 DEFAULT_SNAPSTREAM_IDLE_THRESHOLD = 60000 +DEFAULT_SNAPSERVER_PLUGIN_DIR = "/usr/share/snapserver/plug-ins" +DEFAULT_SNAPSERVER_CONFIG_FILE = "/etc/snapserver.conf" +SHIPPED_SNAPSERVER_CONFIG_FILE = ( + pathlib.Path(__file__).parent / "snapserver" / "snapserver.conf" +).resolve() # Socket path template for control script communication # The {queue_id} placeholder will be replaced with the actual queue ID diff --git a/music_assistant/providers/snapcast/provider.py b/music_assistant/providers/snapcast/provider.py index f46f9195..2e405b7d 100644 --- a/music_assistant/providers/snapcast/provider.py +++ b/music_assistant/providers/snapcast/provider.py @@ -34,9 +34,12 @@ from music_assistant.providers.snapcast.constants import ( CONF_STREAM_IDLE_THRESHOLD, CONF_USE_EXTERNAL_SERVER, CONTROL_SCRIPT, + DEFAULT_SNAPSERVER_CONFIG_FILE, + DEFAULT_SNAPSERVER_PLUGIN_DIR, DEFAULT_SNAPSERVER_PORT, MASS_ANNOUNCEMENT_POSTFIX, MASS_STREAM_PREFIX, + SHIPPED_SNAPSERVER_CONFIG_FILE, SNAPWEB_DIR, ) from music_assistant.providers.snapcast.ma_stream import SnapcastMAStream @@ -77,13 +80,18 @@ class SnapCastProvider(PlayerProvider): _snapcast_ma_streams_lock: asyncio.Lock @property - def use_queue_control(self) -> bool: + def queue_control_available(self) -> bool: """Return whether queue-based control scripts are available. Indicates if the Snapcast control script has been successfully initialized and can be used to control playback via a queue-specific control channel. """ - return self._controlscript_available + return ( + self._use_builtin_server + and self._controlscript_available + and self._snapserver_started is not None + and self._snapserver_started.is_set() + ) async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" @@ -93,6 +101,13 @@ class SnapCastProvider(PlayerProvider): self._stop_called = False self._controlscript_available = False if self._use_builtin_server: + if Path(DEFAULT_SNAPSERVER_CONFIG_FILE).exists(): + self._snapcast_server_config_file = DEFAULT_SNAPSERVER_CONFIG_FILE + else: + # Fallback for dev environments without a Snapserver config file. + # If the file is missing, Snapserver silently ignores all command-line arguments. + self._snapcast_server_config_file = str(SHIPPED_SNAPSERVER_CONFIG_FILE) + self._snapcast_server_host = "127.0.0.1" self._snapcast_server_control_port = DEFAULT_SNAPSERVER_PORT self._snapcast_server_buffer_size = cast( @@ -177,36 +192,39 @@ class SnapCastProvider(PlayerProvider): self.logger.info("Stopping, built-in Snapserver") if self._snapserver_runner and not self._snapserver_runner.done(): self._snapserver_runner.cancel() - if self._snapserver_started is not None: - self._snapserver_started.clear() - def _setup_controlscript(self) -> bool: + def _setup_controlscript(self) -> str | None: """Copy control script to plugin directory (blocking I/O). - :return: True if successful, False otherwise. + :return: plugin dir if successful, None otherwise. """ - plugin_dir = Path("/usr/share/snapserver/plug-ins") - control_dest = plugin_dir / "control.py" logger = self.logger.getChild("snapserver") - try: - plugin_dir.mkdir(parents=True, exist_ok=True) - # Clean up existing file - control_dest.unlink(missing_ok=True) - if not CONTROL_SCRIPT.exists(): - logger.warning("Control script does not exist: %s", CONTROL_SCRIPT) - return False - # Copy the control script to the plugin directory - shutil.copy2(CONTROL_SCRIPT, control_dest) - # Ensure it's executable - control_dest.chmod(0o755) - logger.debug("Copied controlscript to: %s", control_dest) - return True - except (OSError, PermissionError) as err: - logger.warning( - "Could not copy controlscript (metadata/control disabled): %s", - err, - ) - return False + if not CONTROL_SCRIPT.exists(): + logger.warning("Control script does not exist: %s", CONTROL_SCRIPT) + return None + + candidates = ( + Path(DEFAULT_SNAPSERVER_PLUGIN_DIR), + # fallback directory for dev environments + Path(self.mass.storage_path) / "snapcast" / "plugins", + ) + for plugin_dir in candidates: + control_dest = plugin_dir / "control.py" + try: + plugin_dir.mkdir(parents=True, exist_ok=True) + # Clean up existing file + control_dest.unlink(missing_ok=True) + + # Copy the control script to the plugin directory + shutil.copy2(CONTROL_SCRIPT, control_dest) + # Ensure it's executable + control_dest.chmod(0o755) + logger.debug("Copied controlscript to: %s", control_dest) + return str(plugin_dir) + except (OSError, PermissionError) as err: + logger.debug("Could not copy controlscript to %s : %s", plugin_dir, err) + logger.warning("Could not copy controlscript (metadata/control disabled)") + return None async def _builtin_server_runner(self) -> None: """Start running the builtin snapserver.""" @@ -253,12 +271,13 @@ class SnapCastProvider(PlayerProvider): "snapserver", # config settings taken from # https://raw.githubusercontent.com/badaix/snapcast/86cd4b2b63e750a72e0dfe6a46d47caf01426c8d/server/etc/snapserver.conf + f"--config={self._snapcast_server_config_file}", f"--server.datadir={self.mass.storage_path}", "--http.enabled=true", "--http.port=1780", f"--http.doc_root={SNAPWEB_DIR}", - "--tcp.enabled=true", - f"--tcp.port={self._snapcast_server_control_port}", + "--tcp-control.enabled=true", + f"--tcp-control.port={self._snapcast_server_control_port}", "--stream.sampleformat=48000:16:2", f"--stream.buffer={self._snapcast_server_buffer_size}", f"--stream.chunk_ms={self._snapcast_server_chunk_ms}", @@ -266,6 +285,13 @@ class SnapCastProvider(PlayerProvider): f"--stream.send_to_muted={str(self._snapcast_server_send_to_muted).lower()}", f"--streaming_client.initial_volume={self._snapcast_server_initial_volume}", ] + loop = asyncio.get_running_loop() + plugin_dir = await loop.run_in_executor(None, self._setup_controlscript) + if plugin_dir is not None: + args.append(f"--stream.plugin_dir={plugin_dir}") + self._controlscript_available = True + + started_handle: asyncio.Handle | None = None async with AsyncProcess(args, stdout=True, name="snapserver") as snapserver_proc: try: # keep reading from stdout until exit @@ -276,13 +302,11 @@ class SnapCastProvider(PlayerProvider): if "(Snapserver) Version 0." in line: # delay init a small bit to prevent race conditions # where we try to connect too soon - self.mass.loop.call_later(2, self._snapserver_started.set) - # Copy control script after snapserver starts - # (run in executor to avoid blocking) - loop = asyncio.get_running_loop() - self._controlscript_available = await loop.run_in_executor( - None, self._setup_controlscript - ) + if started_handle is None: + started_handle = self.mass.loop.call_later( + 2, self._snapserver_started.set + ) + except asyncio.CancelledError: # Currently, MA doesn't guarantee a defined shutdown order; # Make sure to close socket servers before @@ -296,6 +320,13 @@ class SnapCastProvider(PlayerProvider): self._snapcast_ma_streams.clear() raise + finally: + if started_handle is not None: + started_handle.cancel() + if self._snapserver_started is not None: + self._snapserver_started.clear() + self._controlscript_available = False + def _get_ma_id(self, snap_client_id: str) -> str: search_dict = self._ids_map.inverse ma_id = search_dict.get(snap_client_id) @@ -569,7 +600,7 @@ class SnapCastProvider(PlayerProvider): stream_name=stream_name, filter_settings_owner=filter_settings_owner, source_id=source_id, - use_cntrl_script=bool(queue_id) and self.use_queue_control, + use_cntrl_script=bool(queue_id) and self.queue_control_available, destroy_on_stop=destroy_on_stop, ) self._snapcast_ma_streams[stream_name] = stream diff --git a/music_assistant/providers/snapcast/snapserver/snapserver.conf b/music_assistant/providers/snapcast/snapserver/snapserver.conf new file mode 100644 index 00000000..f18252cb --- /dev/null +++ b/music_assistant/providers/snapcast/snapserver/snapserver.conf @@ -0,0 +1,244 @@ +############################################################################### +# ______ # +# / _____) # +# ( (____ ____ _____ ____ ___ _____ ____ _ _ _____ ____ # +# \____ \ | _ \ (____ || _ \ /___)| ___ | / ___)| | | || ___ | / ___) # +# _____) )| | | |/ ___ || |_| ||___ || ____|| | \ V / | ____|| | # +# (______/ |_| |_|\_____|| __/ (___/ |_____)|_| \_/ |_____)|_| # +# |_| # +# # +# Snapserver config file # +# # +############################################################################### + +# default values are commented +# uncomment and edit to change them + +# Settings can be overwritten on command line with: +# "--
.=", e.g. --server.threads=4 + + +# General server settings ##################################################### +# +[server] +# Number of additional worker threads to use +# - For values < 0 the number of threads will be 2 (on single and dual cores) +# or 4 (for quad and more cores) +# - 0 will utilize just the processes main thread and might cause audio drops +# in case there are a couple of longer running tasks, such as encoding +# multiple audio streams +#threads = -1 + +# the pid file when running as daemon (-d or --daemon) +#pidfile = /var/run/snapserver/pid + +# the user to run as when daemonized (-d or --daemon) +#user = snapserver +# the group to run as when daemonized (-d or --daemon) +#group = snapserver + +# directory where persistent data is stored (server.json) +# if empty, data dir will be +# - "/var/lib/snapserver/" when running as daemon +# - "$HOME/.config/snapserver/" when not running as daemon +#datadir = + +# enable mDNS to publish services +#mdns_enabled = true +# +############################################################################### + + +# Secure Socket Layer ######################################################### +# +[ssl] +# Certificate files are either specified by their full or relative path. Certificates with +# relative path are searched for in the current path and in "/etc/snapserver/certs" + +# Certificate file in PEM format +#certificate = + +# Private key file in PEM format +#certificate_key = + +# Password for decryption of the certificate_key (only needed for encrypted certificate_key file) +#key_password = + +# Verify client certificates +#verify_clients = false + +# List of client CA certificate files, can be configured multiple times +#client_cert = +#client_cert = +# +############################################################################### + + +# HTTP RPC #################################################################### +# +[http] +# enable HTTP Control and streaming (HTTP POST and websockets) +#enabled = true + +# address to listen on, can be specified multiple times +# use "0.0.0.0" to bind to any IPv4 address or :: to bind to any IPv6 address +# or "127.0.0.1" or "::1" to bind to localhost IPv4 or IPv6, respectively +# use the address of a specific network interface to just listen on and accept +# connections from that interface +#bind_to_address = :: + +# which port the server should listen on +#port = 1780 + +# Publish HTTP service via mDNS as '_snapcast-http._tcp' +#publish_http = true + +# enable HTTPS Json RPC (HTTPS POST and ssl websockets) +#ssl_enabled = false + +# same as 'bind_to_address' but for SSL +#ssl_bind_to_address = :: + +# same as 'port' but for SSL +#ssl_port = 1788 + +# Publish HTTPS service via mDNS as '_snapcast-https._tcp' +#publish_https = true + +# serve a website from the doc_root location +# disabled if commented or empty +# doc_root = /usr/share/snapserver/snapweb + +# Hostname or IP under which clients can reach this host +# used to serve cached cover art +# use as placeholder for your actual host name +#host = + +# Optional custom URL prefix for generated URLs where clients can reach +# cached album art, to e.g. match scheme behind a reverse proxy. +#url_prefix = https:// +# +############################################################################### + + +# TCP ######################################################################### +# +[tcp-control] +# enable TCP Json RPC +#enabled = true + +# address to listen on, can be specified multiple times +# use "0.0.0.0" to bind to any IPv4 address or :: to bind to any IPv6 address +# or "127.0.0.1" or "::1" to bind to localhost IPv4 or IPv6, respectively +# use the address of a specific network interface to just listen on and accept +# connections from that interface +#bind_to_address = :: + +# which port the control server should listen on +#port = 1705 + +# Publish TCP control service via mDNS as '_snapcast-ctrl._tcp' +#publish = true + +[tcp-streaming] +# enable TCP streaming +#enabled = true + +# address to listen on, can be specified multiple times +# use "0.0.0.0" to bind to any IPv4 address or :: to bind to any IPv6 address +# or "127.0.0.1" or "::1" to bind to localhost IPv4 or IPv6, respectively +# use the address of a specific network interface to just listen on and accept +# connections from that interface +#bind_to_address = :: + +# which port the streaming server should listen on +#port = 1704 + +# Publish TCP streaming service via mDNS as '_snapcast._tcp' +#publish = true +# +############################################################################### + + +# Stream settings ############################################################# +# +[stream] +# source URI of the PCM input stream, can be configured multiple times +# The following notation is used in this paragraph: +# : the whole expression must be replaced with your specific setting +# [square brackets]: the whole expression is optional and can be left out +# [key=value]: if you leave this option out, "value" will be the default for "key" +# +# Format: TYPE://host/path?name=[&codec=][&sampleformat=][&chunk_ms=][&controlscript=[&controlscriptparams=]] +# parameters have the form "key=value", they are concatenated with an "&" character +# parameter "name" is mandatory for all sources, while codec, sampleformat and chunk_ms are optional +# and will override the default codec, sampleformat or chunk_ms settings +# Available types are: +# pipe: pipe:///?name=[&mode=create], mode can be "create" or "read" +# librespot: librespot:///?name=[&username=&password=][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&normalize=false][&autoplay=false][¶ms=] +# note that you need to have the librespot binary on your machine +# sampleformat will be set to "44100:16:2" +# file: file:///?name= +# process: process:///?name=[&wd_timeout=0][&log_stderr=false][¶ms=] +# airplay: airplay:///?name=[&port=5000] +# note that you need to have the airplay binary on your machine +# sampleformat will be set to "44100:16:2" +# tcp server: tcp://:?name=[&mode=server] +# tcp client: tcp://:?name=&mode=client +# alsa: alsa:///?name=&device=[&send_silence=false][&idle_threshold=100][&silence_threshold_percent=0.0] +# meta: meta://///.../?name= +# source = pipe:///tmp/snapfifo?name=default + +# Plugin directory, containing scripts, referred by "controlscript" +#plugin_dir = /usr/share/snapserver/plug-ins + +# Sandbox directory, containing executables, started by "process" and "librespot" streams +#sandbox_dir = /usr/share/snapserver/sandbox + +# Default sample format: :: +#sampleformat = 48000:16:2 + +# Default transport codec +# (flac|ogg|opus|pcm)[:options] +# Start Snapserver with "--stream:codec=:?" to get codec specific options +#codec = flac + +# Default source stream read chunk size [ms]. +# The server will continuously read this number of milliseconds from the source into buffer and pass this buffer to the encoder. +# The encoded buffer is sent to the clients. Some codecs have a higher latency and will need more data, e.g. Flac will need ~26ms chunks +#chunk_ms = 20 + +# Buffer [ms] +# The end-to-end latency, from capturing a sample on the server until the sample is played-out on the client +#buffer = 1000 + +# Send audio to muted clients +#send_to_muted = false +# +############################################################################### + + +# Streaming client options #################################################### +# +[streaming_client] + +# Volume assigned to new snapclients [percent] +# Defaults to 100 if unset +#initial_volume = 100 +# +############################################################################### + + +# Logging options ############################################################# +# +[logging] + +# log sink [null,system,stdout,stderr,file:] +# when left empty: if running as daemon "system" else "stdout" +#sink = + +# log filter :[,:]* +# with tag = * or and level = [trace,debug,info,notice,warning,error,fatal] +#filter = *:info +# +###############################################################################