diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index c0331a8a2..c6c99bd74 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -160,12 +160,15 @@ async def get_provider_configs( 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 @@ async def reload_provider(self, instance_id: str) -> None: 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 @@ async def create_default_provider_config(self, provider_domain: str) -> None: 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 ] diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index 58171ae66..3702c3775 100644 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -953,7 +953,7 @@ async def _sync_syncgroup(self, player_id: str) -> None: 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 diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 82ad51f3d..7d258e3be 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -937,7 +937,6 @@ async def _get_player_ffmpeg_args( "-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 diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 66cdd525e..7cdbfe216 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -199,44 +199,46 @@ async def init_cliraop(self, start_ntp: int) -> None: 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 @@ def send_data(): 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 @@ async def _log_watcher(self) -> None: 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 @@ async def _handle_dacp_request( # noqa: PLR0915 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 @@ async def monitor_prevent_playback(self, player_id: str): 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) diff --git a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 index bcfec2c58..4507cb42b 100755 Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 differ diff --git a/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 b/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 index 3face4f88..8661219a4 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 index 9cc60b1bb..38de7c611 100755 Binary files a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 differ diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index f1b812ad6..27a7c3471 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -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 @@ async def handle_async_init(self) -> None: 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 @@ async def _handle_player_cli_event(self, slimplayer: SlimClient, event: SlimEven 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 @@ async def _handle_buffer_ready(self, slimplayer: SlimClient) -> None: # 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 ) diff --git a/music_assistant/server/providers/spotify/__init__.py b/music_assistant/server/providers/spotify/__init__.py index dbed1bfe0..9492094c8 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -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 @@ async def _get_all_items(self, endpoint, key="items", **kwargs) -> list[dict]: 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 @@ async def _get_data(self, endpoint, tokeninfo: dict | None = None, **kwargs): 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) diff --git a/music_assistant/server/providers/ugp/__init__.py b/music_assistant/server/providers/ugp/__init__.py index a86c76608..51ee24d73 100644 --- a/music_assistant/server/providers/ugp/__init__.py +++ b/music_assistant/server/providers/ugp/__init__.py @@ -228,7 +228,9 @@ async def create_group(self, name: str, members: list[str]) -> Player: 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( diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 65d92921e..9a2f2dc7e 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -515,7 +515,7 @@ async def load_provider(prov_conf: ProviderConfig) -> None: 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: