From: OzGav Date: Fri, 28 Nov 2025 21:07:24 +0000 (+1000) Subject: Fix snapcast crash by copying control.py to plugins directory (#2685) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=7d4efe6b909a870fa475bc15e0c41490e2af9d62;p=music-assistant-server.git Fix snapcast crash by copying control.py to plugins directory (#2685) --- diff --git a/music_assistant/providers/snapcast/player.py b/music_assistant/providers/snapcast/player.py index f9dc408a..71533846 100644 --- a/music_assistant/providers/snapcast/player.py +++ b/music_assistant/providers/snapcast/player.py @@ -25,7 +25,6 @@ from music_assistant.helpers.ffmpeg import FFMpeg from music_assistant.models.player import Player from music_assistant.providers.snapcast.constants import ( CONF_ENTRY_SAMPLE_RATES_SNAPCAST, - CONTROL_SCRIPT, DEFAULT_SNAPCAST_FORMAT, MASS_ANNOUNCEMENT_POSTFIX, MASS_STREAM_PREFIX, @@ -348,12 +347,15 @@ class SnapCastPlayer(Player): # prefer to reuse existing stream if possible if stream := self._get_snapstream(stream_name): return stream - # The control script is used only for music streams in the builtin server # (queue_id is None only for announcement streams). - if self.provider._use_builtin_server and queue_id: + if ( + self.provider._use_builtin_server + and queue_id + and self.provider._controlscript_available + ): extra_args = ( - f"&controlscript={urllib.parse.quote_plus(str(CONTROL_SCRIPT))}" + f"&controlscript={urllib.parse.quote_plus('control.py')}" f"&controlscriptparams=--queueid={urllib.parse.quote_plus(queue_id)}%20" f"--api-port={self.mass.webserver.publish_port}%20" f"--streamserver-ip={self.mass.streams.publish_ip}%20" diff --git a/music_assistant/providers/snapcast/provider.py b/music_assistant/providers/snapcast/provider.py index 7d67587f..9f44435b 100644 --- a/music_assistant/providers/snapcast/provider.py +++ b/music_assistant/providers/snapcast/provider.py @@ -3,7 +3,9 @@ import asyncio import logging import re +import shutil import socket +from pathlib import Path from typing import cast from bidict import bidict @@ -29,6 +31,7 @@ from music_assistant.providers.snapcast.constants import ( CONF_SERVER_TRANSPORT_CODEC, CONF_STREAM_IDLE_THRESHOLD, CONF_USE_EXTERNAL_SERVER, + CONTROL_SCRIPT, DEFAULT_SNAPSERVER_PORT, SNAPWEB_DIR, ) @@ -46,6 +49,7 @@ class SnapCastProvider(PlayerProvider): _ids_map: bidict[str, str] # ma_id / snapclient_id _use_builtin_server: bool _stop_called: bool + _controlscript_available: bool async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" @@ -53,6 +57,7 @@ class SnapCastProvider(PlayerProvider): logging.getLogger("snapcast").setLevel(self.logger.level) self._use_builtin_server = not self.config.get_value(CONF_USE_EXTERNAL_SERVER) self._stop_called = False + self._controlscript_available = False if self._use_builtin_server: self._snapcast_server_host = "127.0.0.1" self._snapcast_server_control_port = DEFAULT_SNAPSERVER_PORT @@ -131,6 +136,34 @@ class SnapCastProvider(PlayerProvider): if self._snapserver_started is not None: self._snapserver_started.clear() + def _setup_controlscript(self) -> bool: + """Copy control script to plugin directory (blocking I/O). + + :return: True if successful, False 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 + async def _builtin_server_runner(self) -> None: """Start running the builtin snapserver.""" assert self._snapserver_started is not None # for type checking @@ -199,6 +232,12 @@ class SnapCastProvider(PlayerProvider): # 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 + ) def _get_ma_id(self, snap_client_id: str) -> str: search_dict = self._ids_map.inverse