A few small improvements to the Chromecast provider (#1025)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 24 Jan 2024 10:37:33 +0000 (11:37 +0100)
committerGitHub <noreply@github.com>
Wed, 24 Jan 2024 10:37:33 +0000 (11:37 +0100)
* Remove alternative app from cast config

* allow next button from cast player itself

* monkey patch cast media controller to lookup cast queue

music_assistant/constants.py
music_assistant/server/controllers/streams.py
music_assistant/server/providers/chromecast/__init__.py

index 65d2c40a92bcd817bf6f222818348dca3927a3f5..ab5b81083e415d161882344fa64c7899f4bc25e0 100755 (executable)
@@ -15,7 +15,7 @@ VARIOUS_ARTISTS_ID_MBID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377"
 
 
 RESOURCES_DIR: Final[pathlib.Path] = (
-    pathlib.Path(__file__).parent.resolve().joinpath("helpers/resources")
+    pathlib.Path(__file__).parent.resolve().joinpath("server/helpers/resources")
 )
 
 ANNOUNCE_ALERT_FILE: Final[str] = str(RESOURCES_DIR.joinpath("announce.mp3"))
index 3ffb213ad689302751ae2626209ab2ef31d1c4e5..6db086343c56a700ae336d807d1596f298bb5aeb 100644 (file)
@@ -39,6 +39,7 @@ from music_assistant.constants import (
     CONF_EQ_TREBLE,
     CONF_OUTPUT_CHANNELS,
     CONF_PUBLISH_IP,
+    SILENCE_FILE,
 )
 from music_assistant.server.helpers.audio import (
     check_audio_support,
@@ -372,6 +373,11 @@ class StreamsController(CoreController):
                     "/{queue_id}/single/{queue_item_id}.{fmt}",
                     self.serve_queue_item_stream,
                 ),
+                (
+                    "*",
+                    "/{queue_id}/command/{command}.mp3",
+                    self.serve_command_request,
+                ),
             ],
         )
 
@@ -758,6 +764,19 @@ class StreamsController(CoreController):
 
         return resp
 
+    async def serve_command_request(self, request: web.Request) -> web.Response:
+        """Handle special 'command' request for a player."""
+        self._log_request(request)
+        queue_id = request.match_info["queue_id"]
+        command = request.match_info["command"]
+        if command == "next":
+            self.mass.create_task(self.mass.player_queues.next(queue_id))
+        return web.FileResponse(SILENCE_FILE)
+
+    def get_command_url(self, player_or_queue_id: str, command: str) -> str:
+        """Get the url for the special command stream."""
+        return f"{self.base_url}/{player_or_queue_id}/command/{command}.mp3"
+
     async def get_flow_stream(
         self,
         queue: PlayerQueue,
index e8b7babc0e8c7586f7d18676919abaea01b7b9e8..013767a09d9ccba4b21fd4cd7ff97fcf2156077a 100644 (file)
@@ -12,8 +12,7 @@ from typing import TYPE_CHECKING
 from uuid import UUID
 
 import pychromecast
-from pychromecast.controllers.bubbleupnp import BubbleUPNPController
-from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIVE
+from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIVE, MediaController
 from pychromecast.controllers.multizone import MultizoneController, MultizoneManager
 from pychromecast.discovery import CastBrowser, SimpleCastListener
 from pychromecast.models import CastInfo
@@ -52,9 +51,6 @@ if TYPE_CHECKING:
     from music_assistant.server.models import ProviderInstanceType
 
 
-CONF_ALT_APP = "alt_app"
-
-
 PLAYER_CONFIG_ENTRIES = (
     ConfigEntry(
         key=CONF_CROSSFADE,
@@ -66,19 +62,25 @@ PLAYER_CONFIG_ENTRIES = (
         "uses a 'flow mode' workaround for this at the cost of on-player metadata.",
         advanced=False,
     ),
-    ConfigEntry(
-        key=CONF_ALT_APP,
-        type=ConfigEntryType.BOOLEAN,
-        label="Use alternate Media app",
-        default_value=False,
-        description="Using the BubbleUPNP Media controller for playback improves "
-        "the playback experience but may not work on non-Google hardware.",
-        advanced=True,
-    ),
     CONF_ENTRY_CROSSFADE_DURATION,
 )
 
 
+# Monkey patch the Media controller here to store the queue items
+_patched_process_media_status_org = MediaController._process_media_status
+
+
+def _patched_process_media_status(self, data):
+    """Process STATUS message(s) of the media controller."""
+    _patched_process_media_status_org(self, data)
+    for status_msg in data.get("status", []):
+        if items := status_msg.get("items"):
+            self.status.items = items
+
+
+MediaController._process_media_status = _patched_process_media_status
+
+
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
@@ -246,16 +248,21 @@ class ChromecastProvider(PlayerProvider):
         if use_flow_mode:
             # In flow mode, all queue tracks are sent to the player as continuous stream.
             # This comes at the cost of metadata (cast does not support ICY metadata).
-            await asyncio.to_thread(
-                castplayer.cc.play_media,
-                url,
-                content_type=f'audio/{url.split(".")[-1].split("?")[0]}',
-                title="Music Assistant",
-                thumb=MASS_LOGO_ONLINE,
+            cc_queue_items = [
+                self._create_cc_queue_item(None, url),
+            ]
+        else:
+            # handle normal playback using the chromecast queue to play items one by one
+            cc_queue_items = [
+                self._create_cc_queue_item(queue_item, url),
+            ]
+        # add a special 'command' item to the queue
+        # this allows for on-player next buttons/commands to still work
+        cc_queue_items.append(
+            self._create_cc_queue_item(
+                None, self.mass.streams.get_command_url(queue_item.queue_id, "next")
             )
-            return
-        # handle normal playback using the chromecast queue to play items one by one
-        cc_queue_items = [self._create_cc_queue_item(queue_item, url)]
+        )
         queuedata = {
             "type": "QUEUE_LOAD",
             "repeatMode": "REPEAT_OFF",  # handled by our queue controller
@@ -292,9 +299,11 @@ class ChromecastProvider(PlayerProvider):
             queue_item=queue_item,
             output_codec=ContentType.FLAC,
         )
+        if cast_queue_items := getattr(castplayer.cc.media_controller.status, "items"):
+            next_item_id = cast_queue_items[-1]["itemId"]
         queuedata = {
             "type": "QUEUE_INSERT",
-            "insertBefore": None,
+            "insertBefore": next_item_id,
             "items": [self._create_cc_queue_item(queue_item, url)],
         }
         media_controller = castplayer.cc.media_controller
@@ -485,7 +494,9 @@ class ChromecastProvider(PlayerProvider):
             castplayer.player.elapsed_time = status.current_time
 
         # active source
-        if status.content_id and castplayer.player_id in status.content_id:
+        if status.content_id and castplayer.player_id in status.content_id:  # noqa: SIM114
+            castplayer.player.active_source = castplayer.player_id
+        elif castplayer.cc.app_id == pychromecast.config.APP_MEDIA_RECEIVER:
             castplayer.player.active_source = castplayer.player_id
         else:
             castplayer.player.active_source = castplayer.cc.app_display_name
@@ -538,12 +549,7 @@ class ChromecastProvider(PlayerProvider):
     async def _launch_app(self, castplayer: CastPlayer) -> None:
         """Launch the default Media Receiver App on a Chromecast."""
         event = asyncio.Event()
-        if use_alt_app := await self.mass.config.get_player_config_value(
-            castplayer.player_id, CONF_ALT_APP
-        ):
-            app_id = pychromecast.config.APP_BUBBLEUPNP
-        else:
-            app_id = pychromecast.config.APP_MEDIA_RECEIVER
+        app_id = pychromecast.config.APP_MEDIA_RECEIVER
 
         if castplayer.cc.app_id == app_id:
             return  # already active
@@ -555,21 +561,8 @@ class ChromecastProvider(PlayerProvider):
             # Quit the previous app before starting splash screen or media player
             if castplayer.cc.app_id is not None:
                 castplayer.cc.quit_app()
-            # Use BubbleUPNP media receiver app if configured
-            # which enables a more rich display but does not work on all players
-            # so its configurable to turn it on/off
-            if use_alt_app:
-                castplayer.logger.debug(
-                    "Launching BubbleUPNPController (%s) as active app.", app_id
-                )
-                controller = BubbleUPNPController()
-                castplayer.cc.register_handler(controller)
-                controller.launch(launched_callback)
-            else:
-                castplayer.logger.debug(
-                    "Launching Default Media Receiver (%s) as active app.", app_id
-                )
-                castplayer.cc.media_controller.launch(launched_callback)
+            castplayer.logger.debug("Launching Default Media Receiver (%s) as active app.", app_id)
+            castplayer.cc.media_controller.launch(launched_callback)
 
         await self.mass.loop.run_in_executor(None, launch)
         await event.wait()
@@ -583,8 +576,31 @@ class ChromecastProvider(PlayerProvider):
         castplayer.status_listener = None
         self.castplayers.pop(castplayer.player_id, None)
 
-    def _create_cc_queue_item(self, queue_item: QueueItem, stream_url: str):
+    def _create_cc_queue_item(self, queue_item: QueueItem | None, stream_url: str):
         """Create CC queue item from MA QueueItem."""
+        if queue_item is None:
+            # flow mode or other special type
+            return {
+                "autoplay": True,
+                "preloadTime": 10,
+                "startTime": 0,
+                "activeTrackIds": [],
+                "media": {
+                    "contentId": stream_url,
+                    "customData": {
+                        "uri": stream_url,
+                        "queue_item_id": stream_url,
+                    },
+                    "contentType": "audio/flac",
+                    "streamType": STREAM_TYPE_LIVE,
+                    "metadata": {
+                        "metadataType": 0,
+                        "title": "Music Assistant",
+                        "images": [{"url": MASS_LOGO_ONLINE}],
+                    },
+                    "duration": None,
+                },
+            }
         duration = int(queue_item.duration) if queue_item.duration else None
         image_url = self.mass.metadata.get_image_url(queue_item.image) if queue_item.image else ""
         if queue_item.media_type == MediaType.TRACK and queue_item.media_item: