Add Spotify connect provider (#1858)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 13 Jan 2025 07:54:11 +0000 (08:54 +0100)
committerGitHub <noreply@github.com>
Mon, 13 Jan 2025 07:54:11 +0000 (08:54 +0100)
14 files changed:
music_assistant/constants.py
music_assistant/controllers/music.py
music_assistant/controllers/players.py
music_assistant/controllers/streams.py
music_assistant/helpers/webserver.py
music_assistant/providers/spotify/bin/librespot-linux-aarch64 [changed mode: 0755->0644]
music_assistant/providers/spotify/bin/librespot-linux-x86_64 [changed mode: 0755->0644]
music_assistant/providers/spotify/bin/librespot-macos-arm64
music_assistant/providers/spotify/helpers.py
music_assistant/providers/spotify_connect/__init__.py [new file with mode: 0644]
music_assistant/providers/spotify_connect/events.py [new file with mode: 0755]
music_assistant/providers/spotify_connect/icon.svg [new file with mode: 0644]
music_assistant/providers/spotify_connect/manifest.json [new file with mode: 0644]
scripts/gen_requirements_all.py

index ee45661d996835206939a485cdc2781daabddf65..875748c1d900d33fe3508ef3442d71d66747a9ee 100644 (file)
@@ -476,6 +476,14 @@ CONF_ENTRY_ENABLE_ICY_METADATA = ConfigEntry(
     "this correctly. If you experience issues with playback, try to disable this setting.",
 )
 
+CONF_ENTRY_WARN_PREVIEW = ConfigEntry(
+    key="preview_note",
+    type=ConfigEntryType.ALERT,
+    label="Please note that this feature/provider is still in early stages. \n\n"
+    "Functionality may still be limited and/or bugs may occur!",
+    required=False,
+)
+
 
 def create_sample_rates_config_entry(
     max_sample_rate: int,
index d33ac4c7fd71b12bbc7606ba75f9bb2d73480091..b53bcb887aa52a92ac4bfc80ea4e67f758cd1c9d 100644 (file)
@@ -803,7 +803,9 @@ class MusicController(CoreController):
         )
 
         # also update playcount in library table
-        ctrl = self.get_controller(media_type)
+        if not (ctrl := self.get_controller(media_type)):
+            # skip non media items (e.g. plugin source)
+            return
         db_item = await ctrl.get_library_item_by_prov_id(item_id, provider_instance_id_or_domain)
         if (
             not db_item
index 76a6610ff1c037d3e4c41b1caf43af80b77c3cd2..06f94543619bf3ff10fad8def516a2656719aa27 100644 (file)
@@ -1370,6 +1370,7 @@ class PlayerController(CoreController):
             title=player_source.name,
             custom_data={"source": source},
         )
+        media.uri = url
         await self.play_media(player.player_id, media)
 
     async def _poll_players(self) -> None:
index dd86f2084d1986984cfe6ea6d1b766410d87cf2a..5087401ffa1bea69abac9eec5291ed9c61f9bad0 100644 (file)
@@ -613,7 +613,7 @@ class StreamsController(CoreController):
         if not (source := await provider.get_source(source_id)):
             raise web.HTTPNotFound(reason=f"Unknown PluginSource: {source_id}")
         try:
-            streamdetails = await provider.get_stream_details(source_id, MediaType.PLUGIN_SOURCE)
+            streamdetails = await provider.get_stream_details(source_id, "plugin_source")
         except Exception:
             err_msg = f"No streamdetails for PluginSource: {source_id}"
             self.logger.error(err_msg)
index 249df4fe2579c3d4878f56ef8623b99db0e60313..64a28c31e956f4c15025695a656f87fff9d04951 100644 (file)
@@ -2,7 +2,8 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Final
+from collections.abc import Coroutine
+from typing import TYPE_CHECKING, Any, Final
 
 from aiohttp import web
 
@@ -99,7 +100,12 @@ class Webserver:
         """Return the port of this webserver."""
         return self._bind_port
 
-    def register_dynamic_route(self, path: str, handler: Awaitable, method: str = "*") -> Callable:
+    def register_dynamic_route(
+        self,
+        path: str,
+        handler: Callable[[web.Request], Coroutine[Any, Any, web.Response]],
+        method: str = "*",
+    ) -> Callable:
         """Register a dynamic route on the webserver, returns handler to unregister."""
         if self._dynamic_routes is None:
             msg = "Dynamic routes are not enabled"
old mode 100755 (executable)
new mode 100644 (file)
index 7b91c8e..17cefd0
Binary files a/music_assistant/providers/spotify/bin/librespot-linux-aarch64 and b/music_assistant/providers/spotify/bin/librespot-linux-aarch64 differ
old mode 100755 (executable)
new mode 100644 (file)
index 1022c14..76441c3
Binary files a/music_assistant/providers/spotify/bin/librespot-linux-x86_64 and b/music_assistant/providers/spotify/bin/librespot-linux-x86_64 differ
index de3c183bfd8c05b6c9982b13bea7b6e0259afd31..e8adb734cb9c1446d2daa7ef609020c745b44e7a 100755 (executable)
Binary files a/music_assistant/providers/spotify/bin/librespot-macos-arm64 and b/music_assistant/providers/spotify/bin/librespot-macos-arm64 differ
index 4675d14fa38cd5713c09dff64b2a95f6144bdf88..8b6c4e78f5f3f64c9dc6206028177c99ed0542ed 100644 (file)
@@ -8,7 +8,7 @@ import platform
 from music_assistant.helpers.process import check_output
 
 
-async def get_librespot_binary():
+async def get_librespot_binary() -> str:
     """Find the correct librespot binary belonging to the platform."""
 
     # ruff: noqa: SIM102
diff --git a/music_assistant/providers/spotify_connect/__init__.py b/music_assistant/providers/spotify_connect/__init__.py
new file mode 100644 (file)
index 0000000..1af556b
--- /dev/null
@@ -0,0 +1,354 @@
+"""
+Spotify Connect plugin for Music Assistant.
+
+We tie a single player to a single Spotify Connect daemon.
+The provider has multi instance support,
+so multiple players can be linked to multiple Spotify Connect daemons.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import pathlib
+from collections.abc import Callable
+from typing import TYPE_CHECKING, cast
+
+from aiohttp.web import Response
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    ContentType,
+    EventType,
+    MediaType,
+    ProviderFeature,
+    QueueOption,
+    StreamType,
+)
+from music_assistant_models.errors import MediaNotFoundError
+from music_assistant_models.media_items import AudioFormat, PluginSource, ProviderMapping
+from music_assistant_models.streamdetails import LivestreamMetadata, StreamDetails
+
+from music_assistant.constants import CONF_ENTRY_WARN_PREVIEW
+from music_assistant.helpers.audio import get_chunksize
+from music_assistant.helpers.process import AsyncProcess
+from music_assistant.models.music_provider import MusicProvider
+from music_assistant.providers.spotify.helpers import get_librespot_binary
+
+if TYPE_CHECKING:
+    from collections.abc import AsyncGenerator
+
+    from aiohttp.web import Request
+    from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
+    from music_assistant_models.event import MassEvent
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant.mass import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+CONF_MASS_PLAYER_ID = "mass_player_id"
+CONF_CUSTOM_NAME = "custom_name"
+CONF_HANDOFF_MODE = "handoff_mode"
+CONNECT_ITEM_ID = "spotify_connect"
+
+EVENTS_SCRIPT = pathlib.Path(__file__).parent.resolve().joinpath("events.py")
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    return SpotifyConnectProvider(mass, manifest, config)
+
+
+async def get_config_entries(
+    mass: MusicAssistant,
+    instance_id: str | None = None,  # noqa: ARG001
+    action: str | None = None,  # noqa: ARG001
+    values: dict[str, ConfigValueType] | None = None,  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    instance_id: id of an existing provider instance (None if new instance setup).
+    action: [optional] action key called from config entries UI.
+    values: the (intermediate) raw values for config entries sent with the action.
+    """
+    return (
+        CONF_ENTRY_WARN_PREVIEW,
+        ConfigEntry(
+            key=CONF_MASS_PLAYER_ID,
+            type=ConfigEntryType.STRING,
+            label="Connected Music Assistant Player",
+            description="Select the player for which you want to enable Spotify Connect.",
+            multi_value=False,
+            options=tuple(
+                ConfigValueOption(x.display_name, x.player_id)
+                for x in mass.players.all(False, False)
+            ),
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_CUSTOM_NAME,
+            type=ConfigEntryType.STRING,
+            label="Name for the Spotify Connect Player",
+            default_value="",
+            description="Select what name should be shown in the Spotify app as speaker name. "
+            "Leave blank to use the Music Assistant player's name",
+            required=False,
+        ),
+        # ConfigEntry(
+        #     key=CONF_HANDOFF_MODE,
+        #     type=ConfigEntryType.BOOLEAN,
+        #     label="Enable handoff mode",
+        #     default_value=False,
+        #     description="The default behavior of the Spotify Connect plugin is to "
+        #     "forward the actual Spotify Connect audio stream as-is to the player. "
+        #     "The Spotify audio is basically just a live audio stream. \n\n"
+        #     "For controlling the playback (and queue contents), "
+        #     "you need to use the Spotify app. Also, depending on the player's "
+        #     "buffering strategy and capabilities, the audio may not be fully in sync with "
+        #     "what is shown in the Spotify app. \n\n"
+        #     "When enabling handoff mode, the Spotify Connect plugin will instead "
+        #     "forward the Spotify playback request to the Music Assistant Queue, so basically "
+        #     "the spotify app can be used to initiate playback, but then MA will take over "
+        #     "the playback and manage the queue, the normal operating mode of MA. \n\n"
+        #     "This mode however means that the Spotify app will not report the actual playback ",
+        #     required=False,
+        # ),
+    )
+
+
+class SpotifyConnectProvider(MusicProvider):
+    """Implementation of a Spotify Connect Plugin."""
+
+    def __init__(
+        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+    ) -> None:
+        """Initialize MusicProvider."""
+        super().__init__(mass, manifest, config)
+        self.mass_player_id = cast(str, self.config.get_value(CONF_MASS_PLAYER_ID))
+        self.cache_dir = os.path.join(self.mass.cache_path, self.instance_id)
+        self._librespot_bin: str | None = None
+        self._stop_called: bool = False
+        self._runner_task: asyncio.Task | None = None  # type: ignore[type-arg]
+        self._librespot_proc: AsyncProcess | None = None
+        self._librespot_started = asyncio.Event()
+        self._player_connected: bool = False
+        self._current_streamdetails: StreamDetails | None = None
+        self._on_unload_callbacks: list[Callable[..., None]] = [
+            self.mass.subscribe(
+                self._on_mass_player_event,
+                (EventType.PLAYER_ADDED, EventType.PLAYER_REMOVED),
+                id_filter=self.mass_player_id,
+            ),
+            self.mass.streams.register_dynamic_route(
+                f"/{self.instance_id}",
+                self._handle_custom_webservice,
+            ),
+        ]
+
+    @property
+    def supported_features(self) -> set[ProviderFeature]:
+        """Return the features supported by this Provider."""
+        return {ProviderFeature.AUDIO_SOURCE}
+
+    @property
+    def name(self) -> str:
+        """Return (custom) friendly name for this provider instance."""
+        if custom_name := cast(str, self.config.get_value(CONF_CUSTOM_NAME)):
+            return f"{self.manifest.name}: {custom_name}"
+        if player := self.mass.players.get(self.mass_player_id):
+            return f"{self.manifest.name}: {player.display_name}"
+        return super().name
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        self._librespot_bin = await get_librespot_binary()
+        if self.mass.players.get(self.mass_player_id):
+            self._setup_player_daemon()
+
+    async def unload(self, is_removed: bool = False) -> None:
+        """Handle close/cleanup of the provider."""
+        self._stop_called = True
+        if self._runner_task and not self._runner_task.done():
+            self._runner_task.cancel()
+        for callback in self._on_unload_callbacks:
+            callback()
+
+    async def get_sources(self) -> list[PluginSource]:
+        """Get all audio sources provided by this provider."""
+        # we only have passive/hidden sources so no need to supply this listing
+        return []
+
+    async def get_source(self, prov_source_id: str) -> PluginSource:
+        """Get AudioSource details by id."""
+        if prov_source_id != CONNECT_ITEM_ID:
+            raise MediaNotFoundError(f"Invalid source id: {prov_source_id}")
+        return PluginSource(
+            item_id=CONNECT_ITEM_ID,
+            provider=self.instance_id,
+            name="Spotify Connect",
+            provider_mappings={
+                ProviderMapping(
+                    item_id=CONNECT_ITEM_ID,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(content_type=ContentType.OGG),
+                )
+            },
+        )
+
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
+        """Return the streamdetails to stream an audiosource provided by this plugin."""
+        self._current_streamdetails = streamdetails = StreamDetails(
+            item_id=CONNECT_ITEM_ID,
+            provider=self.instance_id,
+            audio_format=AudioFormat(
+                content_type=ContentType.PCM_S16LE,
+            ),
+            media_type=MediaType.PLUGIN_SOURCE,
+            allow_seek=False,
+            can_seek=False,
+            stream_type=StreamType.CUSTOM,
+            extra_input_args=["-readrate", "1.0", "-readrate_initial_burst", "10"],
+        )
+        return streamdetails
+
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """Return the audio stream for the provider item."""
+        if not self._librespot_proc or self._librespot_proc.closed:
+            raise MediaNotFoundError(f"Librespot not ready for: {streamdetails.item_id}")
+        self._player_connected = True
+        chunksize = get_chunksize(streamdetails.audio_format)
+        try:
+            async for chunk in self._librespot_proc.iter_chunked(chunksize):
+                if self._librespot_proc.closed or self._stop_called:
+                    break
+                yield chunk
+        finally:
+            self._player_connected = False
+            await asyncio.sleep(2)
+            if not self._player_connected:
+                # handle situation where the stream is disconnected from the MA player
+                # easiest way to unmark this librespot instance as active player is to close it
+                await self._librespot_proc.close(True)
+
+    async def _librespot_runner(self) -> None:
+        """Run the spotify connect daemon in a background task."""
+        assert self._librespot_bin
+        if not (player := self.mass.players.get(self.mass_player_id)):
+            raise MediaNotFoundError(f"Player not found: {self.mass_player_id}")
+        name = cast(str, self.config.get_value(CONF_CUSTOM_NAME) or player.display_name)
+        self.logger.info("Starting Spotify Connect background daemon %s", name)
+        os.environ["MASS_CALLBACK"] = f"{self.mass.streams.base_url}/{self.instance_id}"
+        try:
+            args: list[str] = [
+                self._librespot_bin,
+                "--name",
+                name,
+                "--cache",
+                self.cache_dir,
+                "--disable-audio-cache",
+                "--bitrate",
+                "320",
+                "--backend",
+                "pipe",
+                "--dither",
+                "none",
+                # disable volume control
+                "--mixer",
+                "softvol",
+                "--volume-ctrl",
+                "fixed",
+                "--initial-volume",
+                "100",
+                # forward events to the events script
+                "--onevent",
+                str(EVENTS_SCRIPT),
+                "--emit-sink-events",
+            ]
+            self._librespot_proc = librespot = AsyncProcess(
+                args, stdout=True, stderr=True, name=f"librespot[{name}]"
+            )
+            await librespot.start()
+            # keep reading logging from stderr until exit
+            async for line in librespot.iter_stderr():
+                if (
+                    not self._librespot_started.is_set()
+                    and "Using StdoutSink (pipe) with format: S16" in line
+                ):
+                    self._librespot_started.set()
+                if "error sending packet Os" in line:
+                    continue
+                if "dropping truncated packet" in line:
+                    continue
+                self.logger.debug(line)
+        except asyncio.CancelledError:
+            await librespot.close(True)
+        finally:
+            self.logger.info("Spotify Connect background daemon stopped for %s", name)
+            # auto restart if not stopped manually
+            if not self._stop_called and self._librespot_started.is_set():
+                self._setup_player_daemon()
+
+    def _setup_player_daemon(self) -> None:
+        """Handle setup of the spotify connect daemon for a player."""
+        self._librespot_started.clear()
+        self._runner_task = asyncio.create_task(self._librespot_runner())
+
+    def _on_mass_player_event(self, event: MassEvent) -> None:
+        """Handle incoming event from linked airplay player."""
+        if event.object_id != self.mass_player_id:
+            return
+        if event.event == EventType.PLAYER_REMOVED:
+            self._stop_called = True
+            self.mass.create_task(self.unload())
+            return
+        if event.event == EventType.PLAYER_ADDED:
+            self._setup_player_daemon()
+            return
+
+    async def _handle_custom_webservice(self, request: Request) -> Response:
+        """Handle incoming requests on the custom webservice."""
+        json_data = await request.json()
+        self.logger.debug("Received metadata on webservice: \n%s", json_data)
+
+        # handle session connected event
+        # this player has become the active spotify connect player
+        # we need to start the playback
+        if not self._player_connected and json_data.get("event") in (
+            "session_connected",
+            "play_request_id_changed",
+        ):
+            # initiate playback by selecting the pluginsource mediaitem on the player
+            pluginsource_item = await self.get_source(CONNECT_ITEM_ID)
+            self.mass.create_task(
+                self.mass.player_queues.play_media(
+                    queue_id=self.mass_player_id,
+                    media=pluginsource_item,
+                    option=QueueOption.REPLACE,
+                )
+            )
+
+        if self._current_streamdetails:
+            # parse metadata fields
+            if "common_metadata_fields" in json_data:
+                title = json_data["common_metadata_fields"].get("name", "Unknown")
+                if artists := json_data.get("track_metadata_fields", {}).get("artists"):
+                    artist = artists[0]
+                else:
+                    artist = "Unknown"
+                if images := json_data["common_metadata_fields"].get("covers"):
+                    image_url = images[0]
+                else:
+                    image_url = None
+                self._current_streamdetails.stream_metadata = LivestreamMetadata(
+                    title=title, artist=artist, image_url=image_url
+                )
+
+        return Response()
diff --git a/music_assistant/providers/spotify_connect/events.py b/music_assistant/providers/spotify_connect/events.py
new file mode 100755 (executable)
index 0000000..06b40bc
--- /dev/null
@@ -0,0 +1,91 @@
+#!/usr/bin/python3
+# type: ignore
+"""Events module for Spotify Connect."""
+
+import json
+import os
+import urllib.request
+from datetime import datetime
+
+player_event = os.getenv("PLAYER_EVENT")
+
+json_dict = {
+    "event_time": str(datetime.now()),
+    "event": player_event,
+}
+
+if player_event in ("session_connected", "session_disconnected"):
+    json_dict["user_name"] = os.environ["USER_NAME"]
+    json_dict["connection_id"] = os.environ["CONNECTION_ID"]
+
+elif player_event == "session_client_changed":
+    json_dict["client_id"] = os.environ["CLIENT_ID"]
+    json_dict["client_name"] = os.environ["CLIENT_NAME"]
+    json_dict["client_brand_name"] = os.environ["CLIENT_BRAND_NAME"]
+    json_dict["client_model_name"] = os.environ["CLIENT_MODEL_NAME"]
+
+elif player_event == "shuffle_changed":
+    json_dict["shuffle"] = os.environ["SHUFFLE"]
+
+elif player_event == "repeat_changed":
+    json_dict["repeat"] = os.environ["REPEAT"]
+
+elif player_event == "auto_play_changed":
+    json_dict["auto_play"] = os.environ["AUTO_PLAY"]
+
+elif player_event == "filter_explicit_content_changed":
+    json_dict["filter"] = os.environ["FILTER"]
+
+elif player_event == "volume_changed":
+    json_dict["volume"] = os.environ["VOLUME"]
+
+elif player_event in ("seeked", "position_correction", "playing", "paused"):
+    json_dict["track_id"] = os.environ["TRACK_ID"]
+    json_dict["position_ms"] = os.environ["POSITION_MS"]
+
+elif player_event in (
+    "unavailable",
+    "end_of_track",
+    "preload_next",
+    "preloading",
+    "loading",
+    "stopped",
+):
+    json_dict["track_id"] = os.environ["TRACK_ID"]
+
+elif player_event == "track_changed":
+    common_metadata_fields = {}
+    item_type = os.environ["ITEM_TYPE"]
+    common_metadata_fields["item_type"] = item_type
+    common_metadata_fields["track_id"] = os.environ["TRACK_ID"]
+    common_metadata_fields["uri"] = os.environ["URI"]
+    common_metadata_fields["name"] = os.environ["NAME"]
+    common_metadata_fields["duration_ms"] = os.environ["DURATION_MS"]
+    common_metadata_fields["is_explicit"] = os.environ["IS_EXPLICIT"]
+    common_metadata_fields["language"] = os.environ["LANGUAGE"].split("\n")
+    common_metadata_fields["covers"] = os.environ["COVERS"].split("\n")
+    json_dict["common_metadata_fields"] = common_metadata_fields
+
+    if item_type == "Track":
+        track_metadata_fields = {}
+        track_metadata_fields["number"] = os.environ["NUMBER"]
+        track_metadata_fields["disc_number"] = os.environ["DISC_NUMBER"]
+        track_metadata_fields["popularity"] = os.environ["POPULARITY"]
+        track_metadata_fields["album"] = os.environ["ALBUM"]
+        track_metadata_fields["artists"] = os.environ["ARTISTS"].split("\n")
+        track_metadata_fields["album_artists"] = os.environ["ALBUM_ARTISTS"].split("\n")
+        json_dict["track_metadata_fields"] = track_metadata_fields
+
+    elif item_type == "Episode":
+        episode_metadata_fields = {}
+        episode_metadata_fields["show_name"] = os.environ["SHOW_NAME"]
+        episode_metadata_fields["description"] = os.environ["DESCRIPTION"]
+        json_dict["episode_metadata_fields"] = episode_metadata_fields
+
+URL = os.environ["MASS_CALLBACK"]
+req = urllib.request.Request(URL)  # noqa: S310
+req.add_header("Content-Type", "application/json; charset=utf-8")
+jsondata = json.dumps(json_dict)
+jsondataasbytes = jsondata.encode("utf-8")
+req.add_header("Content-Length", len(jsondataasbytes))
+response = urllib.request.urlopen(req, jsondataasbytes)  # noqa: S310
diff --git a/music_assistant/providers/spotify_connect/icon.svg b/music_assistant/providers/spotify_connect/icon.svg
new file mode 100644 (file)
index 0000000..843cf99
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1333.33 1333.3" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd"><path d="M666.66 0C298.48 0 0 298.47 0 666.65c0 368.19 298.48 666.65 666.66 666.65 368.22 0 666.67-298.45 666.67-666.65C1333.33 298.49 1034.88.03 666.65.03l.01-.04zm305.73 961.51c-11.94 19.58-37.57 25.8-57.16 13.77-156.52-95.61-353.57-117.26-585.63-64.24-22.36 5.09-44.65-8.92-49.75-31.29-5.12-22.37 8.84-44.66 31.26-49.75 253.95-58.02 471.78-33.04 647.51 74.35 19.59 12.02 25.8 37.57 13.77 57.16zm81.6-181.52c-15.05 24.45-47.05 32.17-71.49 17.13-179.2-110.15-452.35-142.05-664.31-77.7-27.49 8.3-56.52-7.19-64.86-34.63-8.28-27.49 7.22-56.46 34.66-64.82 242.11-73.46 543.1-37.88 748.89 88.58 24.44 15.05 32.16 47.05 17.12 71.46V780zm7.01-189.02c-214.87-127.62-569.36-139.35-774.5-77.09-32.94 9.99-67.78-8.6-77.76-41.55-9.98-32.96 8.6-67.77 41.56-77.78 235.49-71.49 626.96-57.68 874.34 89.18 29.69 17.59 39.41 55.85 21.81 85.44-17.52 29.63-55.89 39.4-85.42 21.8h-.03z" fill="#1ed760" fill-rule="nonzero"/></svg>
diff --git a/music_assistant/providers/spotify_connect/manifest.json b/music_assistant/providers/spotify_connect/manifest.json
new file mode 100644 (file)
index 0000000..89494a0
--- /dev/null
@@ -0,0 +1,9 @@
+{
+  "type": "plugin",
+  "domain": "spotify_connect",
+  "name": "Spotify Connect",
+  "description": "Add Spotify Connect support to ANY Music Assistant player.",
+  "codeowners": ["@music-assistant"],
+  "documentation": "https://music-assistant.io/music-providers/spotify_connect/",
+  "multi_instance": true
+}
index cff09664a4592b9fb6fd9c327c8e609ba9107c1d..89519a36f9dbfa50df85a12fc5c0f39909fe1f77 100644 (file)
@@ -40,7 +40,8 @@ def gather_requirements_from_manifests() -> list[str]:
 
             with open(file_path) as _file:
                 provider_manifest = json.loads(_file.read())
-                dependencies += provider_manifest["requirements"]
+                if "requirements" in provider_manifest:
+                    dependencies += provider_manifest["requirements"]
     return dependencies