Fix snapcast crash by copying control.py to plugins directory (#2685)
authorOzGav <gavnosp@hotmail.com>
Fri, 28 Nov 2025 21:07:24 +0000 (07:07 +1000)
committerGitHub <noreply@github.com>
Fri, 28 Nov 2025 21:07:24 +0000 (22:07 +0100)
music_assistant/providers/snapcast/player.py
music_assistant/providers/snapcast/provider.py

index f9dc408a19896a216b4f104499038ab6c988a53a..71533846f76e707b65fe15c4bb9f5ecfccd44027 100644 (file)
@@ -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"
index 7d67587f3436d440cb2902f5985f97df5e6e52a6..9f44435b8bbe0d368d11f65fc71966f8ddc853d4 100644 (file)
@@ -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