Small fixes (#1135)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 12 Mar 2024 19:34:50 +0000 (20:34 +0100)
committerGitHub <noreply@github.com>
Tue, 12 Mar 2024 19:34:50 +0000 (20:34 +0100)
music_assistant/server/controllers/config.py
music_assistant/server/controllers/players.py
music_assistant/server/controllers/streams.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64
music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64
music_assistant/server/providers/airplay/bin/cliraop-macos-arm64
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/ugp/__init__.py
music_assistant/server/server.py

index c0331a8a2a3d3b3ab72780db86b8840518596e62..c6c99bd74dff47d8523331149d8a98ca7835e861 100644 (file)
@@ -160,12 +160,15 @@ class ConfigController:
         self,
         provider_type: ProviderType | None = None,
         provider_domain: str | None = None,
+        include_values: bool = False,
     ) -> list[ProviderConfig]:
         """Return all known provider configurations, optionally filtered by ProviderType."""
         raw_values: dict[str, dict] = self.get(CONF_PROVIDERS, {})
         prov_entries = {x.domain for x in self.mass.get_provider_manifests()}
         return [
             await self.get_provider_config(prov_conf["instance_id"])
+            if include_values
+            else ProviderConfig.parse([], prov_conf)
             for prov_conf in raw_values.values()
             if (provider_type is None or prov_conf["type"] == provider_type)
             and (provider_domain is None or prov_conf["domain"] == provider_domain)
@@ -317,11 +320,15 @@ class ConfigController:
         await self._load_provider_config(config)
 
     @api_command("config/players")
-    async def get_player_configs(self, provider: str | None = None) -> list[PlayerConfig]:
+    async def get_player_configs(
+        self, provider: str | None = None, include_values: bool = False
+    ) -> list[PlayerConfig]:
         """Return all known player configurations, optionally filtered by provider domain."""
         available_providers = {x.instance_id for x in self.mass.providers}
         return [
             await self.get_player_config(raw_conf["player_id"])
+            if include_values
+            else PlayerConfig.parse([], raw_conf)
             for raw_conf in list(self.get(CONF_PLAYERS, {}).values())
             # filter out unavailable providers
             if raw_conf["provider"] in available_providers
@@ -501,12 +508,14 @@ class ConfigController:
         self.set(conf_key, default_config.to_raw())
 
     @api_command("config/core")
-    async def get_core_configs(
-        self,
-    ) -> list[CoreConfig]:
+    async def get_core_configs(self, include_values: bool = False) -> list[CoreConfig]:
         """Return all core controllers config options."""
         return [
             await self.get_core_config(core_controller)
+            if include_values
+            else CoreConfig.parse(
+                [], self.get(f"{CONF_CORE}/{core_controller}", {"domain": core_controller})
+            )
             for core_controller in CONFIGURABLE_CORE_CONTROLLERS
         ]
 
index 58171ae66649213bf164bd3cb40a266ca88f3551..3702c3775fa23f9044ffa264eac2946341d50aa8 100644 (file)
@@ -953,7 +953,7 @@ class PlayerController(CoreController):
 
     async def _register_syncgroups(self) -> None:
         """Register all (virtual/fake) syncgroup players."""
-        player_configs = await self.mass.config.get_player_configs()
+        player_configs = await self.mass.config.get_player_configs(include_values=True)
         for player_config in player_configs:
             if not player_config.player_id.startswith(SYNCGROUP_PREFIX):
                 continue
index 82ad51f3dd6a55ae6cecd10af61084521bbdfd97..7d258e3be778e12af12c32138877a7cdb0f22133 100644 (file)
@@ -937,7 +937,6 @@ class StreamsController(CoreController):
             "-i",
             "-",
         ]
-        input_args += ["-metadata", 'title="Music Assistant"']
         # select output args
         if output_format.content_type == ContentType.FLAC:
             # set compression level to 0 to prevent issues with cast players
index 66cdd525e66ee12166fc48e44bb6a1eb2ab8f62e..7cdbfe216e5b1dc8f260500b929ab9e818fa1555 100644 (file)
@@ -199,44 +199,46 @@ class AirplayStreamJob:
         player_id = self.airplay_player.player_id
         mass_player = self.mass.players.get(player_id)
         if self.mass.config.get_raw_player_config_value(player_id, CONF_ENCRYPTION, False):
-            extra_args += ["-e"]
+            extra_args += ["-encrypt"]
         if self.mass.config.get_raw_player_config_value(player_id, CONF_ALAC_ENCODE, True):
-            extra_args += ["-a"]
+            extra_args += ["-alac"]
         if "airport" in mass_player.device_info.model.lower():
             # enforce auth on airport express
             extra_args += ["-auth"]
+        for prop in ("et", "md", "am", "pk", "pw"):
+            if prop_value := self.airplay_player.discovery_info.decoded_properties.get(prop):
+                extra_args += [f"-{prop}", prop_value]
+
         sync_adjust = self.mass.config.get_raw_player_config_value(player_id, CONF_SYNC_ADJUST, 0)
         if device_password := self.mass.config.get_raw_player_config_value(
             player_id, CONF_PASSWORD, None
         ):
             # NOTE: This may not work as we might need to do
             # some fancy hashing with the plain password first?!
-            extra_args += ["-P", device_password]
+            extra_args += ["-password", device_password]
         if self.prov.log_level == "DEBUG":
-            extra_args += ["-d", "5"]
+            extra_args += ["-debug", "5"]
         elif self.prov.log_level == "VERBOSE":
-            extra_args += ["-d", "10"]
+            extra_args += ["-debug", "10"]
 
         args = [
             self.prov.cliraop_bin,
-            "-n",
+            "-ntpstart",
             str(start_ntp),
-            "-p",
+            "-port",
             str(self.airplay_player.discovery_info.port),
-            "-w",
+            "-wait",
             str(2000 - sync_adjust),
-            "-v",
+            "-volume",
             str(mass_player.volume_level),
             *extra_args,
             "-dacp",
             self.prov.dacp_id,
-            "-ar",
+            "-activeremote",
             self.active_remote_id,
-            "-md",
-            self.airplay_player.discovery_info.decoded_properties["md"],
-            "-et",
-            self.airplay_player.discovery_info.decoded_properties["et"],
-            str(self.airplay_player.discovery_info.parsed_addresses()[0]),
+            "-udn",
+            str(self.airplay_player.discovery_info.name),
+            self.airplay_player.address,
             "-",
         ]
         if platform.system() == "Darwin":
@@ -288,12 +290,13 @@ class AirplayStreamJob:
             self.airplay_player.logger.debug("sending command %s", command)
         await self.mass.create_task(send_data)
 
-    async def _log_watcher(self) -> None:
+    async def _log_watcher(self) -> None:  # noqa: PLR0915
         """Monitor stderr for the running CLIRaop process."""
         airplay_player = self.airplay_player
         mass_player = self.mass.players.get(airplay_player.player_id)
         logger = airplay_player.logger
         airplay_player.logger.debug("Starting log watcher task...")
+        lost_packets = 0
         async for line in self._cliraop_proc.stderr:
             line = line.decode().strip()  # noqa: PLW2901
             if not line:
@@ -328,7 +331,13 @@ class AirplayStreamJob:
                 self.mass.players.update(airplay_player.player_id)
                 continue
             if "lost packet out of backlog" in line:
-                logger.warning(line)
+                lost_packets += 1
+                if lost_packets == 10:
+                    logger.warning("Packet loss detected, resuming playback...")
+                    queue = self.mass.player_queues.get_active_queue(mass_player.player_id)
+                    await self.mass.player_queues.resume(queue.queue_id)
+                else:
+                    logger.debug(line)
                 continue
             # debug log everything else
             if self.prov.log_level == "VERBOSE":
@@ -950,8 +959,10 @@ class AirplayProvider(PlayerProvider):
             elif "device-prevent-playback=1" in path:
                 # device switched to another source (or is powered off)
                 if active_stream := airplay_player.active_stream:
-                    active_stream.prevent_playback = True
-                    self.mass.create_task(self.monitor_prevent_playback(player_id))
+                    # ignore this if we just started playing to prevent false positives
+                    if mass_player.elapsed_time > 2 and mass_player.state == PlayerState.PLAYING:
+                        active_stream.prevent_playback = True
+                        self.mass.create_task(self.monitor_prevent_playback(player_id))
             elif "device-prevent-playback=0" in path:
                 # device reports that its ready for playback again
                 if active_stream := airplay_player.active_stream:
@@ -1040,12 +1051,12 @@ class AirplayProvider(PlayerProvider):
             count += 1
             if not (airplay_player := self._players.get(player_id)):
                 return
-            if not airplay_player.active_stream:
+            if not (active_stream := airplay_player.active_stream):
                 return
-            if airplay_player.active_stream.start_ntp != prev_ntp:
+            if active_stream.start_ntp != prev_ntp:
                 # checksum
                 return
-            if not airplay_player.active_stream.prevent_playback:
+            if not active_stream.prevent_playback:
                 return
             await asyncio.sleep(0.25)
 
index bcfec2c587a9d54181ff53523510ebf9308215cc..4507cb42bf99075522770c042ddea70c9f9d81e5 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 differ
index 3face4f88e4d90d9cfc27583e05705cdbea196fa..8661219a46a91138450853158af63773d39049b5 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 differ
index 9cc60b1bb007daa8fc4a9adddc8bf249656a3467..38de7c6113fd22af48cc3167feb6aaac31f0e68d 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 differ
index f1b812ad6dfb884e8664b7bb5b463a36ec437998..27a7c347109008c76ba0765c9863fb648107e324 100644 (file)
@@ -93,8 +93,8 @@ class SyncPlayPoint:
     diff: int
 
 
-CONF_CLI_TELNET = "cli_telnet"
-CONF_CLI_JSON = "cli_json"
+CONF_CLI_TELNET_PORT = "cli_telnet_port"
+CONF_CLI_JSON_PORT = "cli_json_port"
 CONF_DISCOVERY = "discovery"
 CONF_DISPLAY = "display"
 CONF_VISUALIZATION = "visualization"
@@ -155,14 +155,14 @@ async def get_config_entries(
     # ruff: noqa: ARG001
     return (
         ConfigEntry(
-            key=CONF_CLI_TELNET,
-            type=ConfigEntryType.BOOLEAN,
-            default_value=True,
-            label="Enable classic Squeezebox Telnet CLI",
+            key=CONF_CLI_TELNET_PORT,
+            type=ConfigEntryType.INTEGER,
+            default_value=9090,
+            label="Classic Squeezebox CLI Port",
             description="Some slimproto based players require the presence of the telnet CLI "
-            " to request more information. "
-            "By default this Telnet CLI is hosted on port 9090 but another port will be chosen if "
-            "that port is already taken. \n\n"
+            " to request more information. \n\n"
+            "By default this CLI is hosted on port 9090 but some players also accept "
+            "a different port. Set to 0 to disable this functionality.\n\n"
             "Commands allowed on this interface are very limited and just enough to satisfy "
             "player compatibility, so security risks are minimized to practically zero."
             "You may safely disable this option if you have no players that rely on this feature "
@@ -170,17 +170,17 @@ async def get_config_entries(
             advanced=True,
         ),
         ConfigEntry(
-            key=CONF_CLI_JSON,
-            type=ConfigEntryType.BOOLEAN,
-            default_value=True,
-            label="Enable JSON-RPC API",
+            key=CONF_CLI_JSON_PORT,
+            type=ConfigEntryType.INTEGER,
+            default_value=9000,
+            label="JSON-RPC CLI/API Port",
             description="Some slimproto based players require the presence of the JSON-RPC "
             "API from LMS to request more information. For example to fetch the album cover "
-            "and other metadata. "
+            "and other metadata. \n\n"
             "This JSON-RPC API is compatible with Logitech Media Server but not all commands "
             "are implemented. Just enough to satisfy player compatibility. \n\n"
-            "This API is hosted on the webserver responsible for streaming to players and thus "
-            "accessible on your local network but security impact should be minimal. "
+            "By default this JSON CLI is hosted on port 9000 but most players also accept "
+            "it on a different port. Set to 0 to disable this functionality.\n\n"
             "You may safely disable this option if you have no players that rely on this feature "
             "or you dont care about the additional metadata.",
             advanced=True,
@@ -228,12 +228,12 @@ class SlimprotoProvider(PlayerProvider):
         self._do_not_resync_before = {}
         self._resync_handle: asyncio.TimerHandle | None = None
         control_port = self.config.get_value(CONF_PORT)
-        enable_telnet = self.config.get_value(CONF_CLI_TELNET)
-        enable_json = self.config.get_value(CONF_CLI_JSON)
+        telnet_port = self.config.get_value(CONF_CLI_TELNET_PORT)
+        json_port = self.config.get_value(CONF_CLI_JSON_PORT)
         logging.getLogger("aioslimproto").setLevel(self.logger.level)
         self.slimproto = SlimServer(
-            cli_port=0 if enable_telnet else None,
-            cli_port_json=0 if enable_json else None,
+            cli_port=telnet_port or None,
+            cli_port_json=json_port or None,
             ip_address=self.mass.streams.publish_ip,
             name="Music Assistant",
             control_port=control_port,
@@ -712,6 +712,10 @@ class SlimprotoProvider(PlayerProvider):
             self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled)
             slimplayer.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
             slimplayer.signal_update()
+        elif event.data == "button jump_fwd":
+            await self.mass.player_queues.next(queue.queue_id)
+        elif event.data == "button jump_rew":
+            await self.mass.player_queues.previous(queue.queue_id)
         elif event.data.startswith("time "):
             # seek request
             _, param = event.data.split(" ", 1)
@@ -825,7 +829,7 @@ class SlimprotoProvider(PlayerProvider):
         # all child's ready (or timeout) - start play
         async with asyncio.TaskGroup() as tg:
             for _client in self._get_sync_clients(player.player_id):
-                timestamp = _client.jiffies + 200
+                timestamp = _client.jiffies + 500
                 sync_delay = self.mass.config.get_raw_player_config_value(
                     _client.player_id, CONF_SYNC_ADJUST, 0
                 )
index dbed1bfe0ebb71754bdc688a0947ff55a27b4eb6..9492094c807bc9c478c17ff80507a5d9bc8b7bf2 100644 (file)
@@ -119,10 +119,12 @@ class SpotifyProvider(MusicProvider):
     _auth_token: str | None = None
     _sp_user: str | None = None
     _librespot_bin: str | None = None
+    # rate limiter needs to be specified on provider-level,
+    # so make it an instance attribute
+    _throttler = Throttler(rate_limit=1, period=1)
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
-        self._throttler = Throttler(rate_limit=1, period=1)
         self._cache_dir = CACHE_DIR
         self._ap_workaround = False
         # try to get a token, raise if that fails
@@ -733,11 +735,12 @@ class SpotifyProvider(MusicProvider):
                 break
         return all_items
 
-    async def _get_data(self, endpoint, tokeninfo: dict | None = None, **kwargs):
+    async def _get_data(self, endpoint, **kwargs):
         """Get data from api."""
         url = f"https://api.spotify.com/v1/{endpoint}"
         kwargs["market"] = "from_token"
         kwargs["country"] = "from_token"
+        tokeninfo = kwargs.pop("tokeninfo", None)
         if tokeninfo is None:
             tokeninfo = await self.login()
         headers = {"Authorization": f'Bearer {tokeninfo["accessToken"]}'}
@@ -748,6 +751,14 @@ class SpotifyProvider(MusicProvider):
                 async with self.mass.http_session.get(
                     url, headers=headers, params=kwargs, ssl=False, timeout=120
                 ) as response:
+                    # handle spotify rate limiter
+                    if response.status == 429:
+                        backoff_time = int(response.headers["Retry-After"])
+                        self.logger.debug(
+                            "Waiting %s seconds on Spotify rate limiter", backoff_time
+                        )
+                        await asyncio.sleep(backoff_time)
+                        return await self._get_data(endpoint, **kwargs)
                     # get text before json so we can log the body in case of errors
                     result = await response.text()
                     result = json_loads(result)
index a86c766082764d99fa100e56421426e0985c3876..51ee24d7398d004d1e880fb121894fedbd022adf 100644 (file)
@@ -228,7 +228,9 @@ class UniversalGroupProvider(PlayerProvider):
 
     async def _register_all_players(self) -> None:
         """Register all (virtual/fake) group players in the Player controller."""
-        player_configs = await self.mass.config.get_player_configs(self.instance_id)
+        player_configs = await self.mass.config.get_player_configs(
+            self.instance_id, include_values=True
+        )
         for player_config in player_configs:
             members = player_config.get_value(CONF_GROUP_MEMBERS)
             self._register_group_player(
index 65d92921e3f7ddc01707e835dd7fc56f8ec13767..9a2f2dc7ee1100b23bed68aaa8ed7fd22135eba9 100644 (file)
@@ -515,7 +515,7 @@ class MusicAssistant:
                 self.config.set(f"{CONF_PROVIDERS}/{prov_conf.instance_id}/last_error", str(exc))
 
         # load all configured (and enabled) providers
-        prov_configs = await self.config.get_provider_configs()
+        prov_configs = await self.config.get_provider_configs(include_values=True)
         async with asyncio.TaskGroup() as tg:
             for prov_conf in prov_configs:
                 if not prov_conf.enabled: