From 71c5785e8a8753284ef8579e3ca3bf2f9c52206f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 23 Feb 2026 12:42:19 +0100 Subject: [PATCH] Fix race condition in player register flow wrt config --- music_assistant/controllers/config.py | 5 --- .../controllers/players/controller.py | 32 ++++++++++--------- music_assistant/models/player.py | 18 ++++++++++- .../providers/_demo_player_provider/player.py | 2 +- .../providers/chromecast/player.py | 2 +- music_assistant/providers/sendspin/player.py | 2 +- .../providers/squeezelite/player.py | 2 +- .../providers/sync_group/player.py | 2 +- .../providers/universal_group/player.py | 2 +- 9 files changed, 40 insertions(+), 27 deletions(-) diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index 016eae6c..f5528ffe 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -1850,11 +1850,6 @@ class ConfigController: all_entries: list[ConfigEntry] = [] output_protocols = player.output_protocols - if not player.available: - # if player is not available, we cannot reliably determine the available protocols - # so we return no options to avoid confusion - return all_entries - # Build options from available output protocols, sorted by priority options: list[ConfigValueOption] = [] default_value: str | None = None diff --git a/music_assistant/controllers/players/controller.py b/music_assistant/controllers/players/controller.py index f0a5e42e..3f4f1099 100644 --- a/music_assistant/controllers/players/controller.py +++ b/music_assistant/controllers/players/controller.py @@ -195,6 +195,7 @@ class PlayerController(ProtocolLinkingMixin, CoreController): for player in list(self._players.values()) if (player.state.available or return_unavailable) and (player.state.enabled or return_disabled) + and player.initialized.is_set() and (provider_filter is None or player.provider.instance_id == provider_filter) and ( not user_filter @@ -1240,13 +1241,21 @@ class PlayerController(ProtocolLinkingMixin, CoreController): player.extra_data[ATTR_FAKE_POWER] = cached_value # finally actually register it - self._players[player_id] = player - # update state without signaling event first (ensure all attributes are set) - player.update_state(signal_event=False) + # Despite the fact that the player is not fully ready yet + # (config not loaded, protocol links not evaluated), + # we already add it to the _players dict here because we + # want to make sure the player is available in the controller + # during the rest of the registration process + # (such as when fetching config or evaluating protocol links). + # We use the 'initialized' attribute to indicate that the player + # is still in the process of being registered so we can filter it out where needed. + self._players[player_id] = player # ensure we fetch and set the latest/full config for the player player_config = await self.mass.config.get_player_config(player_id) player.set_config(player_config) + # update state without signaling event first (ensures all attributes are set) + player.update_state(signal_event=False) # call hook after the player is registered and config is set await player.on_config_updated() @@ -1255,6 +1264,8 @@ class PlayerController(ProtocolLinkingMixin, CoreController): await self._enrich_player_identifiers(player) self._evaluate_protocol_links(player) + # now we're ready to signal the player is added and available + player.set_initialized() self.logger.info( "Player (type %s) registered: %s/%s", player.state.type.value, @@ -1262,26 +1273,17 @@ class PlayerController(ProtocolLinkingMixin, CoreController): player.state.name, ) # signal event that a player was added - if player.state.type != PlayerType.PROTOCOL: self.mass.signal_event( EventType.PLAYER_ADDED, object_id=player.player_id, data=player ) - - # register playerqueue for this player - # Skip if this is a protocol player pending evaluation (queue created when promoted) - if ( - player.state.type != PlayerType.PROTOCOL - and player.player_id not in self._pending_protocol_evaluations - ): + # register playerqueue for this player (if not a protocol player) + if player.state.type != PlayerType.PROTOCOL: await self.mass.player_queues.on_player_register(player) - # always call update to fix special attributes like display name, group volume etc. - player.update_state() - # Schedule debounced update of all players since can_group_with values may change # when a new player is added (provider IDs expand to include the new player) - self._schedule_update_all_players() + self._schedule_update_all_players(5) async def register_or_update(self, player: Player) -> None: """Register a new player on the controller or update existing one.""" diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 65c2f836..9eb6e9e5 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -12,6 +12,7 @@ The final active source can be retrieved by using the 'state' property. from __future__ import annotations +import asyncio import time from abc import ABC from collections.abc import Callable @@ -129,6 +130,7 @@ class Player(ABC): self._extra_attributes: dict[str, Any] = {} self._on_unload_callbacks: list[Callable[[], None]] = [] self.__active_mass_source: str | None = None + self.__initialized = asyncio.Event() # The PlayerState is the (snapshotted) final state of the player # after applying any config overrides and other transformations, # such as the display name and player controls. @@ -727,6 +729,16 @@ class Player(ABC): """Return if the player is enabled.""" return self._config.enabled + @property + @final + def initialized(self) -> asyncio.Event: + """ + Return if the player is initialized. + + Used by player controller to indicate initial registration completed. + """ + return self.__initialized + @property def corrected_elapsed_time(self) -> float | None: """Return the corrected/realtime elapsed time.""" @@ -1196,7 +1208,11 @@ class Player(ABC): """ # TODO: validate that caller is the PlayerController ? self._config = config - self.mass.players.trigger_player_update(self.player_id) + + @final + def set_initialized(self) -> None: + """Set the player as initialized.""" + self.__initialized.set() @final def to_dict(self) -> dict[str, Any]: diff --git a/music_assistant/providers/_demo_player_provider/player.py b/music_assistant/providers/_demo_player_provider/player.py index cb3dc2da..372b6e53 100644 --- a/music_assistant/providers/_demo_player_provider/player.py +++ b/music_assistant/providers/_demo_player_provider/player.py @@ -32,7 +32,7 @@ class DemoPlayer(Player): self._set_attributes() async def on_config_updated(self) -> None: - """Handle logic when the player is loaded or updated.""" + """Handle logic when the PlayerConfig is first loaded or updated.""" # OPTIONAL # This method is optional and should be implemented if you need to handle # any initialization logic after the config was initially loaded or updated. diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 1a68f8b7..d71df7ce 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -326,7 +326,7 @@ class ChromecastPlayer(Player): ] async def on_config_updated(self) -> None: - """Handle config updates - resend Sendspin config if needed.""" + """Handle config load/update - resend Sendspin config if needed.""" if not self.sendspin_mode_enabled: return diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 02b2ab16..8b15fedd 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -412,7 +412,7 @@ class SendspinPlayer(Player): self.update_state() async def on_config_updated(self) -> None: - """Apply preferred format when config changes.""" + """Handle logic when the PlayerConfig is first loaded or updated.""" await self._apply_preferred_format() async def _apply_preferred_format(self) -> None: diff --git a/music_assistant/providers/squeezelite/player.py b/music_assistant/providers/squeezelite/player.py index bef9acb7..f3b53f67 100644 --- a/music_assistant/providers/squeezelite/player.py +++ b/music_assistant/providers/squeezelite/player.py @@ -109,7 +109,7 @@ class SqueezelitePlayer(Player): ) async def on_config_updated(self) -> None: - """Handle logic when the player is registered or the config was updated.""" + """Handle logic when the PlayerConfig is first loaded or updated.""" # set presets and display await self._set_preset_items() await self._set_display() diff --git a/music_assistant/providers/sync_group/player.py b/music_assistant/providers/sync_group/player.py index 0392c46d..e14056bc 100644 --- a/music_assistant/providers/sync_group/player.py +++ b/music_assistant/providers/sync_group/player.py @@ -56,7 +56,7 @@ class SyncGroupPlayer(Player): return None async def on_config_updated(self) -> None: - """Handle logic when the player is loaded or updated.""" + """Handle logic when the PlayerConfig is first loaded or updated.""" # Config is only available after the player was registered self._cache.clear() # clear to prevent loading old is_dynamic default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) diff --git a/music_assistant/providers/universal_group/player.py b/music_assistant/providers/universal_group/player.py index 88b5bafa..5d794a57 100644 --- a/music_assistant/providers/universal_group/player.py +++ b/music_assistant/providers/universal_group/player.py @@ -111,7 +111,7 @@ class UniversalGroupPlayer(Player): } async def on_config_updated(self) -> None: - """Handle logic when the player is loaded or updated.""" + """Handle logic when the PlayerConfig is first loaded or updated.""" static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) self._attr_static_group_members = static_members.copy() if not self.powered: -- 2.34.1