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
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
_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."""
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(
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."""
"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}",
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
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
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)
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
--- /dev/null
+###############################################################################
+# ______ #
+# / _____) #
+# ( (____ ____ _____ ____ ___ _____ ____ _ _ _____ ____ #
+# \____ \ | _ \ (____ || _ \ /___)| ___ | / ___)| | | || ___ | / ___) #
+# _____) )| | | |/ ___ || |_| ||___ || ____|| | \ 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][¶ms=<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][¶ms=<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
+#
+###############################################################################