Snapcast: Introduce fallback Snapcast setup for dev environments (#3108)
authorMischa Siekmann <45062894+gnumpi@users.noreply.github.com>
Mon, 9 Feb 2026 07:34:16 +0000 (08:34 +0100)
committerGitHub <noreply@github.com>
Mon, 9 Feb 2026 07:34:16 +0000 (08:34 +0100)
* 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

music_assistant/providers/snapcast/constants.py
music_assistant/providers/snapcast/provider.py
music_assistant/providers/snapcast/snapserver/snapserver.conf [new file with mode: 0644]

index 4764dcadb03e36632a32ccf7fd52e11cc9d1a60f..8c897c72d7db70df7166153eff8bede916ae4d35 100644 (file)
@@ -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
index f46f91953e1d76ee14844fb96fc0850a8e5b3193..2e405b7d0ab7b953fcbf26ac39299e982454a47e 100644 (file)
@@ -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 (file)
index 0000000..f18252c
--- /dev/null
@@ -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:
+#  "--<section>.<name>=<value>", 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 <hostname> as placeholder for your actual host name
+#host = <hostname>
+
+# 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://<hostname>
+#
+###############################################################################
+
+
+# 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:
+#  <angle brackets>: 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=<name>[&codec=<codec>][&sampleformat=<sampleformat>][&chunk_ms=<chunk ms>][&controlscript=<control script filename>[&controlscriptparams=<control script command line arguments>]]
+#  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:///<path/to/pipe>?name=<name>[&mode=create], mode can be "create" or "read"
+# librespot: librespot:///<path/to/librespot>?name=<name>[&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&normalize=false][&autoplay=false][&params=<generic librepsot process arguments>]
+#  note that you need to have the librespot binary on your machine
+#  sampleformat will be set to "44100:16:2"
+# file: file:///<path/to/PCM/file>?name=<name>
+# process: process:///<path/to/process>?name=<name>[&wd_timeout=0][&log_stderr=false][&params=<process arguments>]
+# airplay: airplay:///<path/to/airplay>?name=<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://<listen IP, e.g. 127.0.0.1>:<port>?name=<name>[&mode=server]
+# tcp client: tcp://<server IP, e.g. 127.0.0.1>:<port>?name=<name>&mode=client
+# alsa: alsa:///?name=<name>&device=<alsa device>[&send_silence=false][&idle_threshold=100][&silence_threshold_percent=0.0]
+# meta: meta:///<name of source#1>/<name of source#2>/.../<name of source#N>?name=<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: <sample rate>:<bits per sample>:<channels>
+#sampleformat = 48000:16:2
+
+# Default transport codec
+# (flac|ogg|opus|pcm)[:options]
+# Start Snapserver with "--stream:codec=<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:<filename>]
+# when left empty: if running as daemon "system" else "stdout"
+#sink =
+
+# log filter <tag>:<level>[,<tag>:<level>]*
+# with tag = * or <log tag> and level = [trace,debug,info,notice,warning,error,fatal]
+#filter = *:info
+#
+###############################################################################